diff --git a/apps/api/src/opendirector_api/agent.py b/apps/api/src/opendirector_api/agent.py index 03aad070..698983a4 100644 --- a/apps/api/src/opendirector_api/agent.py +++ b/apps/api/src/opendirector_api/agent.py @@ -149,6 +149,35 @@ class ConversationCreate(BaseModel): id: UUID +class RunEvents: + """Events of one accepted turn, kept until the turn ends. + + The run belongs to the project, not to the HTTP request that started it: + leaving the project closes the stream, and returning attaches a new one that + replays from the beginning. Only an explicit stop cancels the run. + """ + + def __init__(self): + self.items: list[dict] = [] + self.changed = asyncio.Event() + + def put(self, event: dict) -> None: + self.items.append(event) + self.changed.set() + self.changed = asyncio.Event() + + async def follow(self): + index = 0 + while True: + while index < len(self.items): + event = self.items[index] + index += 1 + yield 'data: ' + json.dumps(event, ensure_ascii=False) + '\n\n' + if event['type'] == 'end': + return + await self.changed.wait() + + def agent_routes(projects, generation, configs, provider=None) -> APIRouter: router = APIRouter(prefix='/api/creation/projects/{project_id}/assistant') conversations = Conversations(projects) @@ -174,12 +203,18 @@ def trace(project_id: UUID, turn_id: UUID, raise ProjectError('轨迹文件不存在。', 404) return FileResponse(file, media_type='text/plain', headers={'Cache-Control': 'no-store'}) - active: dict[UUID, tuple[UUID, asyncio.Task]] = {} + active: dict[UUID, tuple[UUID, asyncio.Task, RunEvents]] = {} admission: dict[UUID, asyncio.Lock] = {} @router.get('') async def conversation(project_id: UUID): - return await asyncio.to_thread(conversations.update, project_id) + value = await asyncio.to_thread(conversations.update, project_id) + running = active.get(project_id) + # A streaming message without an active turn was interrupted (for + # example by a restart); with one, the client can attach to it. + if running and not running[1].done(): + value = {**value, 'activeTurnId': str(running[0])} + return value @router.get('/conversations') async def list_conversations(project_id: UUID): @@ -244,6 +279,14 @@ async def stop(project_id: UUID, turn_id: UUID, x_opendirector_session: UUID = H await running[1] return {'ok': True} + @router.get('/turns/{turn_id}/events') + async def turn_events(project_id: UUID, turn_id: UUID): + await asyncio.to_thread(conversations.path, project_id) + running = active.get(project_id) + if not running or running[0] != turn_id: + raise ProjectError('这轮回复已结束,请重新读取对话。', 404) + return StreamingResponse(running[2].follow(), media_type='text/event-stream') + @router.post('/turns') async def turn(project_id: UUID, value: Turn, x_opendirector_session: UUID = Header(), accept_language: str | None = Header(default=None, alias='Accept-Language')): @@ -273,7 +316,7 @@ async def turn(project_id: UUID, value: Turn, x_opendirector_session: UUID = Hea model_names = {row['id']: row['modelId'] for row in configs.list()} preferences = {kind: {'modelConfigId': model_id, 'modelName': model_names.get(model_id)} for kind, model_id in model_selections.items() if model_id} - queue: asyncio.Queue = asyncio.Queue(maxsize=128) + events = RunEvents() async def produce() -> None: text, status, error, accepted = '', 'complete', None, False activities = [] @@ -288,7 +331,7 @@ async def produce() -> None: 'model': model, 'status': 'complete'} saved = await asyncio.to_thread(conversations.update, project_id, session, begin=user, conversation_id=value.conversationId) accepted = True - await queue.put({'type': 'start', 'conversation': saved}) + events.put({'type': 'start', 'conversation': saved}) context = [] for message in saved['messages'][:-1]: content = message['text'] or '(empty reply)' @@ -326,7 +369,7 @@ async def produce() -> None: else: activities[activities.index(previous)] = activity event = {'type': 'tool', **activity} - await queue.put(event) + events.put(event) except asyncio.CancelledError: status = 'stopped' raise @@ -355,33 +398,19 @@ async def produce() -> None: **({'plan': plan} if plan is not None else {})}, conversation_id=value.conversationId) else: saved = None - if asyncio.current_task().cancelling(): - while not queue.empty(): - queue.get_nowait() - await queue.put({'type': 'end', 'conversation': saved, 'error': error}) + events.put({'type': 'end', 'conversation': saved, 'error': error}) if project_id in active: raise ProjectError('当前工程已有回复正在执行。', 409) task = asyncio.create_task(produce()) - active[project_id] = (value.id, task) + active[project_id] = (value.id, task, events) def release(completed: asyncio.Task) -> None: - if active.get(project_id) == (value.id, completed): + running = active.get(project_id) + if running and running[0] == value.id and running[1] is completed: active.pop(project_id) task.add_done_callback(release) - async def events(): - try: - while True: - event = await queue.get() - yield 'data: ' + json.dumps(event, ensure_ascii=False) + '\n\n' - if event['type'] == 'end': - break - finally: - # A disconnected SSE request must not cancel persistence during cleanup. - with CancelScope(shield=True): - if not task.done() and not task.cancelling(): - task.cancel() - with suppress(asyncio.CancelledError): - await task - return StreamingResponse(events(), media_type='text/event-stream') + # Closing this stream (switching projects, reloading) only detaches + # the viewer; the run continues and can be attached again. + return StreamingResponse(events.follow(), media_type='text/event-stream') return router diff --git a/src/backend/chat.ts b/src/backend/chat.ts index 372d8c2d..5f7de42d 100644 --- a/src/backend/chat.ts +++ b/src/backend/chat.ts @@ -154,7 +154,15 @@ export async function chatRuntimes(id: string, onUpdate?: (items: RuntimeInfo[]) })); return rows; } -export const conversation = (id: string) => request(path(id)); +/** The saved conversation, plus the turn still running for it, if any. */ +export async function conversation( + id: string, +): Promise<{ conversation: Conversation; activeTurnId: string | null }> { + const { activeTurnId, ...saved } = await request< + Conversation & { activeTurnId?: string } + >(path(id)); + return { conversation: saved, activeTurnId: activeTurnId ?? null }; +} export const saveChatDraft = (id: string, draft: ChatDraft) => request(path(id) + "/draft", json("PUT", draft)); export const chatSkills = (id: string, signal?: AbortSignal) => @@ -173,6 +181,37 @@ export async function streamChat( headers: { ...headers, "Content-Type": "application/json" }, signal, }); + await readChatEvents(response, onEvent, t); +} + +/** Thrown when the turn already ended; read the conversation instead. */ +export class ChatTurnEnded extends Error {} + +/** + * Attach to a turn that is still running, for example after returning to the + * project. The server replays the turn from its start event; closing this + * stream only detaches the viewer and never stops the Agent. + */ +export async function attachChat( + id: string, + turn: string, + signal: AbortSignal, + onEvent: (event: ChatEvent) => void, + t: Translate = defaultTranslate, +) { + const response = await fetch("/api" + path(id) + `/turns/${turn}/events`, { + headers, + signal, + }); + if (response.status === 404) throw new ChatTurnEnded(); + await readChatEvents(response, onEvent, t); +} + +async function readChatEvents( + response: Response, + onEvent: (event: ChatEvent) => void, + t: Translate, +) { if (!response.ok) throw await responseError(response); if (!response.body) throw new Error(t("assistant.stream.unavailable")); const reader = response.body.getReader(); diff --git a/src/canvas/useAssistantChat.ts b/src/canvas/useAssistantChat.ts index a2837380..1d674fc8 100644 --- a/src/canvas/useAssistantChat.ts +++ b/src/canvas/useAssistantChat.ts @@ -2,6 +2,9 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react import { rememberAgentSelection, restoreAgentSelection } from "./agentPreferences"; import { errorMessage } from "../backend/api"; import { + attachChat, + ChatTurnEnded, + type ChatEvent, conversation, conversations, activateConversation, @@ -51,10 +54,15 @@ const empty = (): Conversation => ({ modelSelections: {}, }); -function restoreConversation(projectId: string, saved: Conversation): Conversation { +/** A streaming reply is interrupted unless its turn is still running on the server. */ +function restoreConversation( + projectId: string, + saved: Conversation, + running = false, +): Conversation { return { ...restoreAgentSelection(projectId, saved), - messages: saved.messages.map(message => message.status === "streaming" + messages: saved.messages.map(message => message.status === "streaming" && !running ? { ...message, status: "interrupted" } : message), }; } @@ -92,6 +100,7 @@ export function useAssistantChat( const submitting = useRef(false); const uploading = useRef(false); const turnId = useRef(null); + const resume = useRef<(turn: string) => Promise>(async () => {}); const current = useRef(value); current.current = value; // Retain metadata for editor undo after a reference is removed from the draft. @@ -171,10 +180,12 @@ export function useAssistantChat( }); void refreshConversations(); void conversation(projectId) - .then((saved) => { + .then(({ conversation: saved, activeTurnId }) => { if (cancelled) return; - setValue(restoreConversation(projectId, saved)); + setValue(restoreConversation(projectId, saved, Boolean(activeTurnId))); setLoaded(true); + // Navigation only detached the previous view; pick the run back up. + if (activeTurnId) void resume.current(activeTurnId); }) .catch((reason) => { if (!cancelled) @@ -185,7 +196,16 @@ export function useAssistantChat( return () => { cancelled = true; alive.current = false; + // Closing the stream detaches this view only; the server keeps the + // turn running. Release the turn now so the next project starts idle. controller.current?.abort(); + controller.current = null; + submitting.current = false; + turnId.current = null; + setSending(false); + setStarted(false); + setStopping(false); + setThinkingNow(false); uploadController.current?.abort(); listController.current?.abort(); }; @@ -398,18 +418,8 @@ export function useAssistantChat( !current.current.draft.trim() ) return; - submitting.current = true; - setSending(true); - setStarted(false); - setError(""); - setThinkingNow(false); - const abort = new AbortController(); - controller.current = abort; - const id = crypto.randomUUID(); - turnId.current = id; const input = current.current; - const assistantId = crypto.randomUUID(); - try { + await follow(crypto.randomUUID(), async (id, signal, onEvent) => { await flushChatDraft(projectId); await streamChat( projectId, @@ -426,6 +436,47 @@ export function useAssistantChat( modelSelections: input.modelSelections, skills: input.skills ?? [], }, + signal, + onEvent, + t, + ); + }); + }; + /** Re-attach to a turn that kept running while this project was not shown. */ + resume.current = async (id: string) => { + if (switching.current || submitting.current) return; + await follow(id, async (turn, signal, onEvent) => { + try { + await attachChat(projectId, turn, signal, onEvent, t); + } catch (reason) { + if (!(reason instanceof ChatTurnEnded)) throw reason; + // It finished in between: the saved conversation has the final reply. + const { conversation: saved } = await conversation(projectId); + if (alive.current && !signal.aborted) + setValue(restoreConversation(projectId, saved)); + } + }); + }; + const follow = async ( + id: string, + run: ( + id: string, + signal: AbortSignal, + onEvent: (event: ChatEvent) => void, + ) => Promise, + ) => { + submitting.current = true; + setSending(true); + setStarted(false); + setError(""); + setThinkingNow(false); + const abort = new AbortController(); + controller.current = abort; + turnId.current = id; + const assistantId = crypto.randomUUID(); + try { + await run( + id, abort.signal, (event) => { if (!alive.current) return; @@ -479,16 +530,20 @@ export function useAssistantChat( if (event.error) setError(displayError(event.error)); } }, - t, ); } catch (reason) { if (alive.current && !abort.signal.aborted) setError(displayError(errorMessage(reason))); } finally { - submitting.current = false; - turnId.current = null; - controller.current = null; - if (alive.current) { + // A newer view may already own these refs after a project switch. + if (controller.current === abort) { + submitting.current = false; + turnId.current = null; + controller.current = null; + } + if (alive.current && !abort.signal.aborted) { + // Aborting only detaches this view (project switch, unmount); the + // turn keeps running on the server and is resumed on return. setSending(false); setStarted(false); setStopping(false); diff --git a/tests/assistantResume.test.ts b/tests/assistantResume.test.ts new file mode 100644 index 00000000..b5279434 --- /dev/null +++ b/tests/assistantResume.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + attachChat, + ChatTurnEnded, + conversation, + type ChatEvent, +} from "../src/backend/chat.ts"; + +const sse = (events: unknown[]) => + new Response( + events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), + { headers: { "Content-Type": "text/event-stream" } }, + ); + +test("returning to a project replays the running turn from its start", async (t) => { + const saved = { messages: [], draft: "", assets: [], thinking: "auto", modelSelections: {} }; + const fetch = t.mock.method(globalThis, "fetch", async (url: string) => { + assert.ok(url.endsWith("/assistant/turns/turn-1/events")); + return sse([ + { type: "start", conversation: saved }, + { type: "delta", text: "继续" }, + { type: "end", conversation: saved, error: null }, + ]); + }); + const events: ChatEvent[] = []; + await attachChat("project", "turn-1", new AbortController().signal, (event) => + events.push(event), + ); + assert.deepEqual( + events.map((event) => event.type), + ["start", "delta", "end"], + ); + assert.equal(fetch.mock.calls[0]!.arguments[1]?.method, undefined); +}); + +test("a turn that already ended is reported so the saved conversation is read", async (t) => { + t.mock.method(globalThis, "fetch", async () => + Response.json({ detail: "ended", code: "not_found" }, { status: 404 }), + ); + await assert.rejects( + attachChat("project", "turn-1", new AbortController().signal, () => {}), + ChatTurnEnded, + ); +}); + +test("the running turn id stays out of the conversation that drafts are saved from", async (t) => { + t.mock.method(globalThis, "fetch", async () => + Response.json({ + conversationId: "c", + messages: [{ id: "m", role: "assistant", text: "", status: "streaming" }], + draft: "", + assets: [], + thinking: "auto", + modelSelections: {}, + activeTurnId: "turn-1", + }), + ); + const result = await conversation("project"); + assert.equal(result.activeTurnId, "turn-1"); + assert.equal("activeTurnId" in result.conversation, false); + assert.equal(result.conversation.messages[0]!.status, "streaming"); +}); diff --git a/tests/test_assistant_conversations.py b/tests/test_assistant_conversations.py index ca75fb57..111913c3 100644 --- a/tests/test_assistant_conversations.py +++ b/tests/test_assistant_conversations.py @@ -205,15 +205,69 @@ async def finished(self, run): assert frames(reply)[-1]['conversation']['messages'][-1]['text'] == '新回复' -def test_sse_disconnect_saves_partial_reply_and_releases_project(assistant, monkeypatch): +def test_sse_disconnect_keeps_the_run_and_a_new_stream_resumes_it(assistant, monkeypatch): + """#81: leaving the project closes the stream but must not stop the Agent.""" + client, path, _ = assistant + release, attached = threading.Event(), threading.Event() + async def stream(self, run): + yield {'type': 'delta', 'text': '断线前的回复'} + await asyncio.to_thread(release.wait, 10) + yield {'type': 'delta', 'text': ',返回后完成'} + monkeypatch.setattr(agent.AgentRuntimes, 'stream', stream) + follow, calls = agent.RunEvents.follow, [] + async def counted(self): + calls.append(self) + if len(calls) > 1: + attached.set() + async for item in follow(self): + yield item + monkeypatch.setattr(agent.RunEvents, 'follow', counted) + turn_id = str(uuid4()) + disconnect_turn(client, path, turn_id) + + current = client.get(path).json() + assert current['activeTurnId'] == turn_id + assert current['messages'][-1]['status'] == 'streaming' + assert client.post(path + '/conversations', json={'id': str(uuid4())}).status_code == 409 + with ThreadPoolExecutor() as pool: + pending = pool.submit(client.get, path + f'/turns/{turn_id}/events') + try: + assert attached.wait(5) + finally: + release.set() + events = frames(pending.result(timeout=10)) + assert events[0]['type'] == 'start' + assert [item['text'] for item in events if item['type'] == 'delta'] == ['断线前的回复', ',返回后完成'] + saved = events[-1]['conversation']['messages'][-1] + assert events[-1]['type'] == 'end' and events[-1]['error'] is None + assert saved['status'] == 'complete' and saved['text'] == '断线前的回复,返回后完成' + assert 'activeTurnId' not in client.get(path).json() + assert client.get(path + f'/turns/{turn_id}/events').status_code == 404 + assert client.post(path + '/conversations', json={'id': str(uuid4())}).status_code == 200 + + +def test_explicit_stop_after_disconnect_still_stops_the_run(assistant, monkeypatch): client, path, _ = assistant async def stream(self, run): yield {'type': 'delta', 'text': '断线前的回复'} await asyncio.Event().wait() monkeypatch.setattr(agent.AgentRuntimes, 'stream', stream) + turn_id = str(uuid4()) + disconnect_turn(client, path, turn_id) + assert client.get(path).json()['activeTurnId'] == turn_id + assert client.post(path + f'/turns/{turn_id}/stop').status_code == 200 + current = client.get(path).json() + assert 'activeTurnId' not in current + assert current['messages'][-1]['status'] == 'stopped' + assert current['messages'][-1]['text'] == '断线前的回复' + assert client.post(path + '/conversations', json={'id': str(uuid4())}).status_code == 200 + + +def disconnect_turn(client, path, turn_id): + """Start a turn over raw ASGI and close the stream after its first delta.""" async def disconnect(): disconnected = asyncio.Event() - body = json.dumps({'id': str(uuid4()), 'runtime': 'default', 'text': '断线测试'}).encode() + body = json.dumps({'id': turn_id, 'runtime': 'default', 'text': '断线测试'}).encode() requested = False async def receive(): nonlocal requested @@ -235,7 +289,3 @@ async def send_event(message): await asyncio.wait_for(client.app(scope, receive, send_event), 5) assert disconnected.is_set() client.portal.call(disconnect) - saved = client.get(path).json()['messages'][-1] - assert saved['status'] == 'stopped' - assert saved['text'] == '断线前的回复' - assert client.post(path + '/conversations', json={'id': str(uuid4())}).status_code == 200