fix(studio): reclassify a chat turn as a production task when the server starts one

根因: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 事件的回退路由使用。
This commit is contained in:
Ma
2026-07-15 15:15:12 +08:00
parent 8073aaa9f8
commit 28f2923fc0
7 changed files with 183 additions and 7 deletions
+91
View File
@@ -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<string, unknown> | 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<string, unknown> : 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<string, unknown>) => 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;
+4
View File
@@ -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 {
@@ -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);
@@ -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 }]);
});
});
@@ -234,8 +234,13 @@ export function deserializeMessages(
export function mergeTaskExecution(
messages: ReadonlyArray<Message>,
execution: ToolExecution,
taskExecution: ToolExecution,
): ReadonlyArray<Message> {
// 任务快照必然来自后台生产任务:恢复出的卡带 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;
@@ -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<string, unknown> | 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 {
+3
View File
@@ -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) --