fix(studio): persist the production instruction to the transcript at task start

根因:确认式生产任务(create_book/short_run/write_next 等)的用户指令,
要等任务完成/失败后才随助手工具消息一起传给 appendManualSessionMessages,
且只作为 request_started.input 落在 transcript 里——transcript 恢复逻辑
(deriveBookSessionFromTranscript)只把 role=user 的 message 事件还原成
用户气泡,从不读取 request_started.input。后果:
1. 任务运行期间刷新页面,GET session 的消息里没有这轮用户指令,用户气泡消失;
2. 任务完成后指令依然不会作为用户气泡恢复;
3. 并行聊天的消息先写入 transcript,重新加载后聊天排在生产指令前,顺序倒置。

修法:任务开始执行前(预留名额与快照检查之后、executeConfirmedProductionAction
之前)就把用户指令作为 role=user 的消息事件写进 transcript(时间戳为开始时刻);
成功与失败(ConfirmedActionExecutionError)两条完成路径改为只追加助手工具消息,
instruction 参数传空字符串,指令不会写第二遍。刷新场景下用户气泡来自 transcript、
运行中任务卡来自快照 mergeTaskExecution 按 execution id 合并,两者共存且不重复。

测试:server.test.ts 新增三条真实 transcript 往返用例(运行中刷新含指令气泡、
并行聊天轮后顺序按真实时间、失败路径指令只写一次),并把三条既有断言更新为
"开始预写指令 + 完成只补助手消息"的新契约;action.test.ts 新增前端恢复用例
(transcript 用户气泡 + 运行中任务卡合并不重复)。
This commit is contained in:
Ma
2026-07-15 11:46:31 +08:00
parent 790aace6b9
commit fc6babc335
3 changed files with 265 additions and 5 deletions
+209 -3
View File
@@ -3119,6 +3119,194 @@ describe("createStudioServer daemon lifecycle", () => {
});
});
// 下面三个用例把 appendManualSessionMessages / loadBookSession 接回真实实现,
// 走真实 transcript 文件验证:确认式生产任务的用户指令必须在任务开始时就
// 写进 transcript(而不是任务完成后才补写),完成/失败时只追加助手工具消息。
async function wireRealSessionTranscript() {
const actual = await vi.importActual<typeof import("@actalk/inkos-core")>("@actalk/inkos-core");
appendManualSessionMessagesMock.mockImplementation(actual.appendManualSessionMessages);
loadBookSessionMock.mockImplementation(
(projectRoot: string, sessionId: string) => actual.loadBookSession(projectRoot, sessionId),
);
return actual;
}
function hangingShortFictionTool(): { resolveShort: () => void } {
const handle = { resolveShort: () => undefined as void };
createShortFictionRunToolMock.mockImplementationOnce(() => ({
name: "short_fiction_run",
execute: vi.fn(() => new Promise((resolve) => {
handle.resolveShort = () => resolve({
content: [{ type: "text", text: "短篇《雨夜档案》已完成。" }],
details: {
kind: "short_fiction_created",
storyId: "rainy-archive",
finalMarkdownPath: "shorts/rainy-archive/final/full.md",
},
});
})),
}));
return handle;
}
it("persists the production instruction to the transcript at task start", async () => {
const actual = await wireRealSessionTranscript();
await actual.createAndPersistBookSession(root, null, "instr-short-session", "short");
const handle = hangingShortFictionTool();
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const instruction = "写一篇雨夜档案馆悬疑短篇。";
const pendingTask = app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction,
sessionId: "instr-short-session",
sessionKind: "short",
actionSource: "button",
requestedIntent: "short_run",
actionPayload: { shortRun: { direction: "雨夜档案馆悬疑", chapters: 12, cover: false } },
}),
});
await vi.waitFor(async () => {
const task = await loadStudioTaskSnapshot(root, "instr-short-session");
expect(task?.execution.status).toBe("running");
});
// 任务运行中刷新:transcript 里已有这轮用户指令,用户气泡不会消失。
const midRun = await app.request("http://localhost/api/v1/sessions/instr-short-session");
expect(midRun.status).toBe(200);
const midBody = await midRun.json() as {
session: { messages: Array<{ role: string; content: string }> };
task?: { execution: { status: string } };
};
expect(midBody.task?.execution.status).toBe("running");
expect(midBody.session.messages).toEqual([
expect.objectContaining({ role: "user", content: instruction }),
]);
handle.resolveShort();
const response = await pendingTask;
expect(response.status).toBe(200);
// 任务完成后:指令只出现一次,助手工具消息排在其后。
const final = await app.request("http://localhost/api/v1/sessions/instr-short-session");
const finalBody = await final.json() as {
session: { messages: Array<{ role: string; content: string; toolExecutions?: Array<{ tool: string; status: string }> }> };
};
expect(finalBody.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(
finalBody.session.messages.filter((message) => message.role === "user" && message.content === instruction),
).toHaveLength(1);
expect(finalBody.session.messages[1]?.toolExecutions?.[0]).toMatchObject({
tool: "short_fiction_run",
status: "completed",
});
});
it("keeps real-time transcript order when a chat round lands during the production task", async () => {
const actual = await wireRealSessionTranscript();
await actual.createAndPersistBookSession(root, null, "order-short-session", "short");
const handle = hangingShortFictionTool();
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const instruction = "写一篇雨夜档案馆悬疑短篇。";
const pendingTask = app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction,
sessionId: "order-short-session",
sessionKind: "short",
actionSource: "button",
requestedIntent: "short_run",
actionPayload: { shortRun: { direction: "雨夜档案馆悬疑", chapters: 12, cover: false } },
}),
});
await vi.waitFor(async () => {
const task = await loadStudioTaskSnapshot(root, "order-short-session");
expect(task?.execution.status).toBe("running");
});
// 任务运行期间插入一轮并行聊天(真实 agent 路径会把聊天消息写进同一份 transcript)。
await actual.appendManualSessionMessages(root, "order-short-session", [
{ role: "user", content: "任务进度如何?", timestamp: Date.now() },
{
role: "assistant",
content: [{ type: "text", text: "短篇任务还在运行。" }],
api: "anthropic-messages",
provider: "openai",
model: "gpt-5.4",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
] as never, "任务进度如何?", { sessionKind: "short" });
handle.resolveShort();
const response = await pendingTask;
expect(response.status).toBe(200);
const final = await app.request("http://localhost/api/v1/sessions/order-short-session");
const finalBody = await final.json() as {
session: { messages: Array<{ role: string; content: string }> };
};
// 重新加载后按真实时间排序:生产指令在并行聊天之前,任务结果在最后。
expect(finalBody.session.messages.map((message) => [message.role, message.content])).toEqual([
["user", instruction],
["user", "任务进度如何?"],
["assistant", "短篇任务还在运行。"],
["assistant", expect.stringContaining("短篇《雨夜档案》已完成。")],
]);
});
it("does not duplicate the instruction when the production task fails", async () => {
const actual = await wireRealSessionTranscript();
await actual.createAndPersistBookSession(root, null, "fail-short-session", "short");
createShortFictionRunToolMock.mockImplementationOnce(() => ({
name: "short_fiction_run",
execute: vi.fn(async () => {
throw new Error("short upstream failed");
}),
}));
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const instruction = "写一篇会失败的短篇。";
const response = await app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction,
sessionId: "fail-short-session",
sessionKind: "short",
actionSource: "button",
requestedIntent: "short_run",
actionPayload: { shortRun: { direction: "会失败的短篇", chapters: 12, cover: false } },
}),
});
expect(response.status).toBeGreaterThanOrEqual(400);
const final = await app.request("http://localhost/api/v1/sessions/fail-short-session");
const finalBody = await final.json() as {
session: { messages: Array<{ role: string; content: string }> };
};
expect(finalBody.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(
finalBody.session.messages.filter((message) => message.role === "user" && message.content === instruction),
).toHaveLength(1);
expect(finalBody.session.messages[1]?.content).toContain("short upstream failed");
});
it("rejects a second confirmed production task with 409 while one is still running", async () => {
let resolveInitBook!: () => void;
initBookMock.mockImplementationOnce(() => new Promise<void>((resolve) => {
@@ -3487,11 +3675,20 @@ describe("createStudioServer daemon lifecycle", () => {
},
session: { sessionId: "play-session-1", sessionKind: "play" },
});
// 任务开始时:指令作为 user 消息预写进 transcript。
expect(appendManualSessionMessagesMock).toHaveBeenCalledWith(
root,
"play-session-1",
[expect.objectContaining({ role: "user", content: "确认启动旧档案馆之夜。" })],
"确认启动旧档案馆之夜。",
{ sessionKind: "play" },
);
// 任务完成时:只补助手工具消息,指令不再重复写入。
expect(appendManualSessionMessagesMock).toHaveBeenCalledWith(
root,
"play-session-1",
expect.any(Array),
"确认启动旧档案馆之夜。",
"",
expect.objectContaining({
sessionKind: "play",
legacyDisplay: {
@@ -3594,11 +3791,20 @@ describe("createStudioServer daemon lifecycle", () => {
});
expect(writeNextChapterMock).toHaveBeenCalledWith("demo-book");
expect(runAgentSessionMock).not.toHaveBeenCalled();
// 任务开始时:指令作为 user 消息预写进 transcript。
expect(appendManualSessionMessagesMock).toHaveBeenCalledWith(
root,
"agent-session-1",
[expect.objectContaining({ role: "user", content: "继续" })],
"继续",
{ sessionKind: "book" },
);
// 任务完成时:只补助手工具消息,指令不再重复写入。
expect(appendManualSessionMessagesMock).toHaveBeenCalledWith(
root,
"agent-session-1",
expect.any(Array),
"继续",
"",
expect.objectContaining({
sessionKind: "book",
legacyDisplay: {
@@ -3652,7 +3858,7 @@ describe("createStudioServer daemon lifecycle", () => {
root,
"agent-session-1",
expect.any(Array),
"继续",
"",
expect.objectContaining({
sessionKind: "book",
legacyDisplay: {
+14 -2
View File
@@ -4727,6 +4727,16 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
});
}
// 任务开始前先把用户指令作为 user 消息写进 transcript:任务运行期间
// 刷新页面时,用户气泡能从 transcript 恢复;并行聊天随后写入的消息
// 也会按真实时间排在指令之后。完成/失败路径只追加助手工具消息
//instruction 传空字符串),指令不会写第二遍。
await appendManualSessionMessages(root, bookSession.sessionId, [{
role: "user",
content: instruction,
timestamp: Date.now(),
}], instruction, { sessionKind });
const exec = await executeConfirmedProductionAction({
pipeline,
root,
@@ -4769,6 +4779,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
const responseText = exec.result ?? pick(surfaceLanguage, "已完成。", "Done.");
const responseForUser = suppressManualTextForTool(exec) ? "" : responseText;
// 指令已在任务开始时写入 transcript,这里只补助手工具消息。
await appendManualSessionMessages(root, bookSession.sessionId, [
manualToolAssistantMessage(
responseText,
@@ -4776,7 +4787,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
configuredEntry?.service ?? reqService ?? config.llm.provider,
reqModel ?? config.llm.model,
),
], instruction, manualToolAppendOptions(sessionKind, exec));
], "", manualToolAppendOptions(sessionKind, exec));
await refreshBookSessionFromTranscript();
broadcast("agent:complete", { instruction, activeBookId: createdBookId ?? agentBookId, sessionId: bookSession.sessionId, sessionKind });
return c.json({
@@ -4796,6 +4807,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
broadcast("book:error", { bookId: pendingBookId, sessionId: streamSessionId, error: message });
}
if (error instanceof ConfirmedActionExecutionError) {
// 指令已在任务开始时写入 transcript,失败时同样只补助手工具消息。
await appendManualSessionMessages(root, bookSession.sessionId, [
manualToolAssistantMessage(
message,
@@ -4803,7 +4815,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
configuredEntry?.service ?? reqService ?? config.llm.provider,
reqModel ?? config.llm.model,
),
], instruction, manualToolAppendOptions(sessionKind, error.exec)).catch(() => undefined);
], "", manualToolAppendOptions(sessionKind, error.exec)).catch(() => undefined);
await refreshBookSessionFromTranscript().catch(() => undefined);
}
broadcast("agent:error", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId, sessionKind, error: message });
@@ -323,6 +323,48 @@ describe("chat message actions", () => {
});
});
it("restores the transcript user bubble alongside the running task card without duplication", async () => {
const store = createTestStore();
fetchJson.mockResolvedValueOnce({
session: { sessionId: "short-session-2", bookId: null, sessionKind: "short", title: null },
});
const sessionId = await store.getState().createSession(null, "short");
fetchJson.mockResolvedValueOnce({
session: {
sessionId,
bookId: null,
sessionKind: "short",
title: null,
// 任务开始时预写进 transcript 的用户指令
messages: [{ role: "user", content: "写一篇雨夜档案馆悬疑短篇。", timestamp: 5 }],
},
task: {
version: 1,
sessionId,
requestedIntent: "short_run",
updatedAt: 20,
execution: {
id: "short-task-2",
tool: "short_fiction_run",
label: "生成短篇",
status: "running",
startedAt: 10,
},
},
});
await store.getState().loadSessionDetail(sessionId);
const messages = store.getState().sessions[sessionId]?.messages ?? [];
// 用户气泡(来自 transcript)+ 运行中任务卡(来自快照 merge)共存且不重复
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({ role: "user", content: "写一篇雨夜档案馆悬疑短篇。" });
expect(messages[1]?.toolExecutions?.[0]).toMatchObject({ id: "short-task-2", status: "running" });
expect(
messages.filter((message) => message.role === "user" && message.content === "写一篇雨夜档案馆悬疑短篇。"),
).toHaveLength(1);
});
it("ignores a stale terminal task snapshot replayed onto a new agent stream", async () => {
const store = createTestStore();
const sessionId = store.getState().createDraftSession(null, "short");