mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) --
|
||||
|
||||
Reference in New Issue
Block a user