From 28f2923fc03b1ec1af8f9c2c98092fcdca5e5720 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 15 Jul 2026 15:15:12 +0800 Subject: [PATCH] fix(studio): reclassify a chat turn as a production task when the server starts one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:free-text"写下一章"(无 requestedIntent)在服务端经 isWriteNextProductionRequest 命中写章启发式后按后台生产任务执行,但前端 isConfirmedProductionSend 对 requestedIntent === undefined 直接返回 false, 该轮被当成聊天轮(isChatStreaming=true)。用户点"停止"时 ChatPage 按 聊天轮走 scope=chat,abortAgentSession 在 agent 会话注册表里找不到会话 (任务不在其中),第一次点停止什么都停不掉。 修法(前端无法预知服务端启发式,用事件驱动重分类): - 服务端:确认式生产任务分支的 tool:start 广播加 background: true 标记, 聊天轮工具的 tool:start 不带。 - 前端 stream-events:收到带标记的 tool:start 且当前 isChatStreaming=true 时,把本轮重分类为任务轮——isChatStreaming 归 false(停止按钮据此走 scope=all 拿到任务控制器,用户可继续聊天),isStreaming 维持 true; 挂起的 fetch 返回后仍由 sendMessage 的 finally 按"是否还有任务在跑"收尾, 不会重复收尾或把任务流关掉。 - ToolExecution 加可选 background 字段并记到工具卡上;mergeTaskExecution 从任务快照恢复的卡同样补该标记(快照来源必然是后台任务,终态快照替换 整个 execution 时标记不能丢)。该标记供后续无 id 事件的回退路由使用。 --- packages/studio/src/api/server.test.ts | 91 +++++++++++++++++++ packages/studio/src/api/server.ts | 4 + .../store/chat/slices/message/action.test.ts | 57 ++++++++++++ .../store/chat/slices/message/runtime.test.ts | 15 ++- .../src/store/chat/slices/message/runtime.ts | 7 +- .../chat/slices/message/stream-events.ts | 13 ++- packages/studio/src/store/chat/types.ts | 3 + 7 files changed, 183 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/api/server.test.ts b/packages/studio/src/api/server.test.ts index 0cc46648..b8396f1e 100644 --- a/packages/studio/src/api/server.test.ts +++ b/packages/studio/src/api/server.test.ts @@ -3644,6 +3644,97 @@ describe("createStudioServer daemon lifecycle", () => { await ssePump; }, 60_000); + it("marks confirmed production tool:start broadcasts as background while chat tool starts stay untagged", async () => { + loadBookSessionMock.mockResolvedValue({ + sessionId: "bg-flag-session", + bookId: null, + sessionKind: "short", + title: null, + messages: [], + events: [], + draftRounds: [], + createdAt: 1, + updatedAt: 1, + }); + const { createStudioServer } = await import("./server.js"); + const app = createStudioServer(cloneProjectConfig() as never, root); + + const sseResponse = await app.request("http://localhost/api/v1/events"); + const sseEvents: Array<{ event: string; data: Record | null }> = []; + const sseReader = sseResponse.body!.getReader(); + const ssePump = (async () => { + const decoder = new TextDecoder(); + let buffer = ""; + try { + for (;;) { + const { done, value } = await sseReader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let frameEnd = buffer.indexOf("\n\n"); + while (frameEnd !== -1) { + const lines = buffer.slice(0, frameEnd).split("\n"); + buffer = buffer.slice(frameEnd + 2); + const eventName = lines.find((line) => line.startsWith("event:"))?.slice("event:".length).trim(); + const dataRaw = lines.find((line) => line.startsWith("data:"))?.slice("data:".length).trim(); + if (eventName) { + sseEvents.push({ event: eventName, data: dataRaw ? JSON.parse(dataRaw) as Record : null }); + } + frameEnd = buffer.indexOf("\n\n"); + } + } + } catch { + // abort 断开 SSE 连接时 read 会抛错,这是本测试收尾的正常关闭路径 + } + })(); + await vi.waitFor(() => expect(sseEvents.some((entry) => entry.event === "ping")).toBe(true)); + + // 确认式生产任务分支:tool:start 必须带 background 标记,前端据此把 + // free-text 命中任务分支的聊天轮重分类为任务轮。 + const taskResponse = await app.request("http://localhost/api/v1/agent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + instruction: "写一篇冷库账本短篇。", + sessionId: "bg-flag-session", + sessionKind: "short", + actionSource: "button", + requestedIntent: "short_run", + actionPayload: { shortRun: { direction: "冷库账本悬疑", cover: false } }, + }), + }); + expect(taskResponse.status).toBe(200); + const findToolStart = (predicate: (data: Record) => boolean) => + sseEvents.find((entry) => entry.event === "tool:start" && entry.data !== null && predicate(entry.data)); + await vi.waitFor(() => { + expect(findToolStart((data) => String(data.id ?? "").startsWith("direct-short_run-"))).toBeDefined(); + }); + expect(findToolStart((data) => String(data.id ?? "").startsWith("direct-short_run-"))?.data).toMatchObject({ + sessionId: "bg-flag-session", + background: true, + }); + + // 聊天轮工具的 tool:start 不带 background 标记,前端维持聊天轮分类。 + runAgentSessionMock.mockImplementationOnce(async (config: { onEvent?: (event: unknown) => void }) => { + config.onEvent?.({ type: "tool_execution_start", toolCallId: "chat-tool-1", toolName: "read", args: {} }); + return { responseText: "读完了。", messages: [] }; + }); + const chatResponse = await app.request("http://localhost/api/v1/agent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + instruction: "帮我读一下大纲。", + sessionId: "bg-flag-session", + sessionKind: "short", + }), + }); + expect(chatResponse.status).toBe(200); + await vi.waitFor(() => expect(findToolStart((data) => data.id === "chat-tool-1")).toBeDefined()); + expect(findToolStart((data) => data.id === "chat-tool-1")?.data?.background).toBeUndefined(); + + await sseReader.cancel(); + await ssePump; + }); + it("aborts only the chat round when scope=chat and leaves the production task controller alive", async () => { let resolveRun!: () => void; let capturedSignal: AbortSignal | undefined; diff --git a/packages/studio/src/api/server.ts b/packages/studio/src/api/server.ts index 59ccd182..218c8f49 100644 --- a/packages/studio/src/api/server.ts +++ b/packages/studio/src/api/server.ts @@ -1653,12 +1653,16 @@ async function executeConfirmedProductionAction(args: { await args.onTaskChange(exec); + // background: true 标明这是后台生产任务的工具启动(聊天轮工具不带)。 + // free-text 命中写章启发式时前端在发送时无法预知这轮会按任务执行, + // 收到这个标记后把该轮从聊天轮重分类为任务轮。 broadcast("tool:start", { sessionId: args.streamSessionId, id, tool: tool.name, args: params, stages: exec.stages?.map(stage => stage.label), + background: true, }); try { diff --git a/packages/studio/src/store/chat/slices/message/action.test.ts b/packages/studio/src/store/chat/slices/message/action.test.ts index 793351d2..2e72fb52 100644 --- a/packages/studio/src/store/chat/slices/message/action.test.ts +++ b/packages/studio/src/store/chat/slices/message/action.test.ts @@ -742,6 +742,63 @@ describe("chat message actions", () => { await sent; }); + it("reclassifies a free-text turn as a production task when the server starts a background task", async () => { + const store = createTestStore(); + const sessionId = store.getState().createDraftSession("demo-book", "book"); + store.getState().setSelectedModel("deepseek-v4-flash", "kkaiapi"); + + let resolveAgent!: (value: unknown) => void; + fetchJson + .mockResolvedValueOnce({ session: { sessionId, bookId: "demo-book", sessionKind: "book" } }) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveAgent = resolve; + })); + + // free-text 发送:前端无法预知服务端会命中写章启发式,先按聊天轮对待 + const sent = store.getState().sendMessage(sessionId, "写下一章"); + await vi.waitFor(() => expect(fakeEventSources).toHaveLength(1)); + expect(store.getState().sessions[sessionId]).toMatchObject({ isStreaming: true, isChatStreaming: true }); + + // 普通聊天轮工具启动(不带 background 标记)不改变轮次分类 + fakeEventSources[0]?.emit("tool:start", { sessionId, id: "chat-tool-0", tool: "read" }); + expect(store.getState().sessions[sessionId]).toMatchObject({ isChatStreaming: true }); + fakeEventSources[0]?.emit("tool:end", { sessionId, id: "chat-tool-0", tool: "read", result: "ok" }); + + // 服务端广播带 background 标记的 tool:start:这轮实际按后台生产任务执行 + fakeEventSources[0]?.emit("tool:start", { + sessionId, + id: "direct-write_next-1", + tool: "sub_agent", + args: { agent: "writer", bookId: "demo-book" }, + background: true, + }); + + // 重分类:isChatStreaming 归 false(停止按钮据此走 scope=all,能拿到任务控制器), + // isStreaming 维持 true(任务还在跑),工具卡带上 background 标记 + expect(store.getState().sessions[sessionId]).toMatchObject({ isStreaming: true, isChatStreaming: false }); + const taskExecution = (store.getState().sessions[sessionId]?.messages ?? []) + .flatMap((message) => message.toolExecutions ?? []) + .find((execution) => execution.id === "direct-write_next-1"); + expect(taskExecution).toMatchObject({ status: "running", background: true }); + + // 任务结束:tool:end 收尾任务卡,挂起的 fetch 返回后 finally 正常收尾(不重复、不残留) + fakeEventSources[0]?.emit("tool:end", { + sessionId, + id: "direct-write_next-1", + tool: "sub_agent", + result: { content: [{ type: "text", text: "第 3 章已完成" }] }, + }); + resolveAgent({ response: "", session: { sessionId, sessionKind: "book" } }); + await sent; + + expect(store.getState().sessions[sessionId]).toMatchObject({ + isStreaming: false, + isChatStreaming: false, + stream: null, + }); + expect(fakeEventSources[0]?.closed).toBe(true); + }); + it("routes executionId-tagged llm progress to the task card's active stage", async () => { const store = createTestStore(); const sessionId = await setupRunningTaskSession(store); diff --git a/packages/studio/src/store/chat/slices/message/runtime.test.ts b/packages/studio/src/store/chat/slices/message/runtime.test.ts index 3eea3f8e..d233b1d0 100644 --- a/packages/studio/src/store/chat/slices/message/runtime.test.ts +++ b/packages/studio/src/store/chat/slices/message/runtime.test.ts @@ -231,7 +231,7 @@ describe("withToolExecutions", () => { }); describe("mergeTaskExecution", () => { - it("adds a persisted running task as a restorable tool card", () => { + it("adds a persisted running task as a background-tagged restorable tool card", () => { const execution = exec({ id: "short-task-1", tool: "short_fiction_run", @@ -242,12 +242,15 @@ describe("mergeTaskExecution", () => { const messages = mergeTaskExecution([], execution); + // 任务快照必然来自后台生产任务:恢复出的卡带 background 标记, + // 供无 id 事件的回退路由跳过它。 + const tagged = { ...execution, background: true }; expect(messages).toEqual([ expect.objectContaining({ role: "assistant", timestamp: 10, - toolExecutions: [execution], - parts: [{ type: "tool", execution }], + toolExecutions: [tagged], + parts: [{ type: "tool", execution: tagged }], }), ]); }); @@ -265,9 +268,11 @@ describe("mergeTaskExecution", () => { const messages = mergeTaskExecution(mergeTaskExecution([], running), completed); + // 终态快照替换整个 execution 时不能丢 background 标记 + const tagged = { ...completed, background: true }; expect(messages).toHaveLength(1); - expect(messages[0]?.toolExecutions).toEqual([completed]); - expect(messages[0]?.parts).toEqual([{ type: "tool", execution: completed }]); + expect(messages[0]?.toolExecutions).toEqual([tagged]); + expect(messages[0]?.parts).toEqual([{ type: "tool", execution: tagged }]); }); }); diff --git a/packages/studio/src/store/chat/slices/message/runtime.ts b/packages/studio/src/store/chat/slices/message/runtime.ts index b6da237c..e8e9fc68 100644 --- a/packages/studio/src/store/chat/slices/message/runtime.ts +++ b/packages/studio/src/store/chat/slices/message/runtime.ts @@ -234,8 +234,13 @@ export function deserializeMessages( export function mergeTaskExecution( messages: ReadonlyArray, - execution: ToolExecution, + taskExecution: ToolExecution, ): ReadonlyArray { + // 任务快照必然来自后台生产任务:恢复出的卡带 background 标记,供无 id + // 事件的回退路由跳过它。终态快照替换整个 execution,标记也要跟着补回来。 + const execution: ToolExecution = taskExecution.background + ? taskExecution + : { ...taskExecution, background: true }; let found = false; const next = messages.map((message) => { const hasDirectExecution = message.toolExecutions?.some((item) => item.id === execution.id) ?? false; diff --git a/packages/studio/src/store/chat/slices/message/stream-events.ts b/packages/studio/src/store/chat/slices/message/stream-events.ts index 6ef77481..8a552128 100644 --- a/packages/studio/src/store/chat/slices/message/stream-events.ts +++ b/packages/studio/src/store/chat/slices/message/stream-events.ts @@ -426,6 +426,13 @@ export function attachSessionStreamListeners({ try { const data = event.data ? JSON.parse(event.data) : null; if (!sessionMatchesEvent(sessionId, data) || !data?.tool) return; + // 服务端在确认式生产任务的 tool:start 上带 background: true。free-text + // 命中服务端写章启发式时,前端发送时把这轮当成了聊天轮 + //(isChatStreaming=true);收到该标记说明这轮实际按后台任务执行, + // 需要重分类:isChatStreaming 归 false(停止按钮据此走 scope=all 才能 + // 拿到任务控制器,用户也可以继续聊天),isStreaming 维持 true(任务在跑)。 + // 挂起的 fetch 返回后由 sendMessage 的 finally 按"是否还有任务在跑"收尾。 + const background = data.background === true; flushTextDeltas(); set((state) => ({ sessions: updateSession(state.sessions, sessionId, (runtime) => { @@ -464,11 +471,15 @@ export function attachSessionStreamListeners({ args: data.args as Record | undefined, stages, startedAt: Date.now(), + ...(background ? { background: true } : {}), }, }); const flat = deriveFlat(parts); - return { messages: replaceLast(messages, { ...stream, ...flat, parts }) }; + return { + messages: replaceLast(messages, { ...stream, ...flat, parts }), + ...(background && runtime.isChatStreaming ? { isChatStreaming: false } : {}), + }; }), })); } catch { diff --git a/packages/studio/src/store/chat/types.ts b/packages/studio/src/store/chat/types.ts index 8607215f..bb2cd9a0 100644 --- a/packages/studio/src/store/chat/types.ts +++ b/packages/studio/src/store/chat/types.ts @@ -32,6 +32,9 @@ export interface ToolExecution { logs?: string[]; startedAt: number; completedAt?: number; + // 后台生产任务的工具卡(来自带 background 标记的 tool:start 或任务快照恢复)。 + // 无 executionId 事件的回退路由据此跳过任务卡,只挂聊天轮工具卡。 + background?: boolean; } // -- Message parts (chronologically ordered for rendering) --