fix(agent): 恢复桌面端执行轨迹

This commit is contained in:
2026-09-08 08:04:31 +08:00
parent 8b5d1f3b08
commit 2010778780
4 changed files with 109 additions and 8 deletions
+6 -1
View File
@@ -62,7 +62,12 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["http://127.0.0.1:5173", "http://localhost:5173"],
allow_origins=[
"http://127.0.0.1:5173",
"http://localhost:5173",
"http://tauri.localhost",
"tauri://localhost",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+12
View File
@@ -77,6 +77,18 @@ def test_health() -> None:
assert response.model_dump() == {"status": "ok"}
@pytest.mark.parametrize("origin", ["http://tauri.localhost", "tauri://localhost"])
def test_desktop_origins_can_read_streaming_api(origin: str) -> None:
from fastapi.testclient import TestClient
from app.main import app
response = TestClient(app).get("/health", headers={"Origin": origin})
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == origin
def test_mcp_create_and_trust_are_not_executed_on_event_loop(monkeypatch) -> None:
from app import routes
from app.contracts import McpServerCreateRequest, McpServerTrustRequest
+42 -4
View File
@@ -50,8 +50,7 @@ export const useAgentStore = defineStore('agent', () => {
)
const currentStep = computed(() => {
const tc = events.value.filter((e) => e.event === 'ToolCall').length
return tc
return activeRun.value?.current_step ?? 0
})
async function loadTools() {
@@ -79,6 +78,7 @@ export const useAgentStore = defineStore('agent', () => {
async function loadRun(runId: string) {
const version = ++selectionVersion
stopStream()
error.value = null
activeRunId.value = runId
resetEvents()
permissionRequest.value = null
@@ -91,9 +91,38 @@ export const useAgentStore = defineStore('agent', () => {
events.value = []
toolCalls.value = []
permissionRequest.value = null
try {
await loadPersistedTrace(runId, () => version === selectionVersion && activeRunId.value === runId)
error.value = null
} catch (cause) {
if (version !== selectionVersion || activeRunId.value !== runId) return
error.value = cause instanceof Error ? cause.message : String(cause)
}
if (version !== selectionVersion || activeRunId.value !== runId) return
if (terminal(activeRun.value?.status)) {
connectionState.value = 'idle'
return
}
subscribe(runId)
}
async function loadPersistedTrace(runId: string, current: () => boolean) {
let cursor = lastSequence
do {
const trace = await agentService.getAgentTrace(runId, {
after_sequence: cursor,
limit: 500,
})
if (!current()) return
trace.items.forEach(processEvent)
const run = runs.value.find((item) => item.run_id === runId)
if (run) run.status = trace.status
if (!trace.has_more) return
if (trace.next_sequence <= cursor) throw new Error(t('运行轨迹分页游标未前进', 'Agent trace cursor did not advance'))
cursor = trace.next_sequence
} while (current())
}
function processEvent(event: AgentEvent) {
// 服务端会先回放历史再发送实时事件,以 run_id + sequence 去重保证幂等。
if (seenSequences.has(event.sequence)) return
@@ -107,7 +136,10 @@ export const useAgentStore = defineStore('agent', () => {
const data = event.data
const run = runs.value.find((item) => item.run_id === event.run_id)
if (event.event === 'RunStarted' && run) run.status = 'running'
if (event.event === 'ToolCall') {
if (event.event === 'ModelCallStarted' && run) {
const step = Number(data.step)
if (Number.isFinite(step)) run.current_step = Math.max(run.current_step, step)
} else if (event.event === 'ToolCall') {
toolCalls.value.push({
tool_call_id: String(data.tool_call_id ?? ''),
name: String(data.name ?? 'unknown'),
@@ -168,11 +200,17 @@ export const useAgentStore = defineStore('agent', () => {
retryTimer = setTimeout(async () => {
retryTimer = null
try {
await loadPersistedTrace(runId, current)
if (!current()) return
const run = await agentService.getAgentRun(runId)
if (!current()) return
const index = runs.value.findIndex(item => item.run_id === runId)
if (index >= 0) runs.value[index] = run
// 即使已结束仍续读一次缺失的尾部事件,保留完整 Trace。
if (terminal(run.status)) {
isRunning.value = false
connectionState.value = 'idle'
return
}
subscribe(runId)
} catch (cause) {
if (current()) interrupted(cause instanceof Error ? cause : new Error(String(cause)))
+49 -3
View File
@@ -2,10 +2,18 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia, disposePinia } from 'pinia'
import { useAgentStore } from './agent'
const mock = vi.hoisted(() => ({ stream: vi.fn(), get: vi.fn(), cancel: vi.fn() }))
vi.mock('@/services/agentService', () => ({ streamAgentEvents: mock.stream, getAgentRun: mock.get, cancelAgentRun: mock.cancel }))
const mock = vi.hoisted(() => ({ stream: vi.fn(), get: vi.fn(), trace: vi.fn(), cancel: vi.fn() }))
vi.mock('@/services/agentService', () => ({ streamAgentEvents: mock.stream, getAgentRun: mock.get, getAgentTrace: mock.trace, cancelAgentRun: mock.cancel }))
let pinia: ReturnType<typeof createPinia>
beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); pinia = createPinia(); setActivePinia(pinia); mock.stream.mockReturnValue({ cancel: vi.fn() }); mock.get.mockImplementation(async id => ({ run_id: id, status: 'running' })) })
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
mock.stream.mockReturnValue({ cancel: vi.fn() })
mock.get.mockImplementation(async id => ({ run_id: id, status: 'running', current_step: 0, max_steps: 6 }))
mock.trace.mockImplementation(async id => ({ run_id: id, status: 'running', items: [], next_sequence: -1, has_more: false }))
})
afterEach(() => { disposePinia(pinia); vi.useRealTimers() })
const handler = () => mock.stream.mock.calls.at(-1)![1]
const event = (sequence: number, name = 'RunStarted') => ({ run_id: 'r', sequence, event: name, data: {}, timestamp: 'now' })
@@ -41,3 +49,41 @@ it('bounds retry attempts and supports explicit retry without losing events', as
store.reconnect()
expect(mock.stream.mock.calls.at(-1)![2]).toBe(1)
})
it('loads every persisted trace page for a completed run without opening SSE', async () => {
mock.get.mockResolvedValue({ run_id: 'r', status: 'completed', current_step: 2, max_steps: 6 })
mock.trace
.mockResolvedValueOnce({
run_id: 'r', status: 'completed',
items: [event(0), event(1, 'ModelCallStarted')], next_sequence: 1, has_more: true,
})
.mockResolvedValueOnce({
run_id: 'r', status: 'completed',
items: [event(2, 'ToolCall'), event(3, 'RunCompleted')], next_sequence: 3, has_more: false,
})
const store = useAgentStore()
await store.loadRun('r')
expect(mock.trace).toHaveBeenNthCalledWith(1, 'r', { after_sequence: -1, limit: 500 })
expect(mock.trace).toHaveBeenNthCalledWith(2, 'r', { after_sequence: 1, limit: 500 })
expect(store.events.map(item => item.sequence)).toEqual([0, 1, 2, 3])
expect(store.currentStep).toBe(2)
expect(mock.stream).not.toHaveBeenCalled()
})
it('fills missing events through REST before reconnecting a live run', async () => {
const store = useAgentStore()
await store.loadRun('r')
handler().onEvent(event(0))
handler().onError(new Error('desktop SSE unavailable'))
mock.trace.mockResolvedValueOnce({
run_id: 'r', status: 'running',
items: [event(1, 'ModelCallStarted'), event(2, 'ToolCall')], next_sequence: 2, has_more: false,
})
await vi.advanceTimersByTimeAsync(1000)
expect(store.events.map(item => item.sequence)).toEqual([0, 1, 2])
expect(mock.stream.mock.calls.at(-1)![2]).toBe(2)
})