diff --git a/backend/app/main.py b/backend/app/main.py index 6426954..ce68242 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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=["*"], diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 2c04625..5be0481 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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 diff --git a/frontend/src/stores/agent.ts b/frontend/src/stores/agent.ts index 13bd07e..59ecda2 100644 --- a/frontend/src/stores/agent.ts +++ b/frontend/src/stores/agent.ts @@ -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))) diff --git a/frontend/src/stores/agentRecovery.spec.ts b/frontend/src/stores/agentRecovery.spec.ts index 2ffab58..4d45fed 100644 --- a/frontend/src/stores/agentRecovery.spec.ts +++ b/frontend/src/stores/agentRecovery.spec.ts @@ -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 -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) +})