fix(ai): stop resumed companion turns from asking answered questions again (#638)

Resuming an interrupted companion turn kept the answered questions in
questions_json, and the client-direct runner ended any run with leftover
questions as interrupted. The question form reappeared after the reply.

Clear questions when a turn resumes, as the server-side runner did before
client-direct calls, and only pause when this run actually asks.

Fixes #637

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yulei666andClaude Opus 5.5 authored and GitHub committed 2026-10-21 23:15:55 +08:00
1 parent d2a87a74a6
commit 00ddaa0fed
4 files changed
+46 -2

No files matched your search

+3 -1
View File
@@ -217,7 +217,9 @@ export const resumeCompanionTurn = async (db: DatabaseAdapter, scope: CompanionS
await listCompanionTurns(db, scope);
const row = await getCompanionTurn(db, scope, id);
if (!row || row.status !== "interrupted") throw new AppError("companion_resume_unavailable", "This request cannot be continued.", 409);
const result = await db.prepare(`UPDATE companion_turns SET status = 'running', expires_at = ?, answers_json = COALESCE(?, answers_json)
// Answered questions are resolved; keeping them would end the resumed run as interrupted again.
const result = await db.prepare(`UPDATE companion_turns SET status = 'running', expires_at = ?, answers_json = COALESCE(?, answers_json),
questions_json = '[]'
WHERE workspace_id = ? AND owner_id = ? AND id = ? AND status = 'interrupted'`).bind(
companionTurnLease(), answers ? JSON.stringify(answers.slice(0, 3)) : null, ...bindScope(scope), id).run();
if (Number(result.meta.changes) !== 1) throw new AppError("companion_busy", "A conversation is already running or this request was already submitted. Refresh to recover its result.", 409);
+26
View File
@@ -434,6 +434,32 @@ describe("companion client-direct HTTP contracts", () => {
expect((await getCompanionTurn(setup.db, scope, payload.id)).status).toBe("completed");
});
test("resume clears answered questions so the resumed turn can complete (#637)", async () => {
const setup = fixture({ loadCredentials: async () => credentials });
const payload = input({ message: "翻译这篇笔记", allowNotes: true });
expect((await setup.request("turns/prepare", "POST", payload)).status).toBe(200);
const asked = await setup.request(`turns/${payload.id}/tools`, "POST", {
name: "ask_user_question",
input: { questions: [{ id: "language", prompt: "翻译成哪种语言?", inputType: "single_select", options: [{ id: "en", label: "English" }] }] },
response: "", process: "",
});
const askedBody = await asked.json();
expect(askedBody.pause).toBe(true);
expect((await setup.request(`turns/${payload.id}/complete`, "POST", { response: "", status: "interrupted" })).status).toBe(200);
expect((await setup.request(`turns/${payload.id}/resume/prepare`, "POST", {})).status).toBe(400);
const resumed = await setup.request(`turns/${payload.id}/resume/prepare`, "POST", {
answers: [{ questionId: "language", optionIds: ["en"] }],
});
expect(resumed.status).toBe(200);
const prepared = await resumed.json();
expect(prepared.turn.questions).toEqual([]);
expect(prepared.messages.at(-1).content).toContain("\"optionIds\":[\"en\"]");
const completed = await setup.request(`turns/${payload.id}/complete`, "POST", { response: "Translated note.", status: "completed" });
expect((await completed.json()).turn).toMatchObject({ status: "completed", questions: [], response: "Translated note." });
});
});
describe("actual AI SDK companion runtime", () => {
@@ -192,3 +192,18 @@ test("browser fallback continues the same prepared turn and completes the stream
expect(events).toContainEqual({ type: "text-delta", text: "Relayed reply" });
expect(events.at(-1)).toMatchObject({ type: "done", turn: { id: preparedTurn.turn.id, status: "completed" } });
});
test("a resumed turn with answered questions completes after the reply (#637)", async () => {
const { streamDirectCompanion } = await import("./companion-direct-stream.ts");
const answered = [{ id: "language", prompt: "Which language?", inputType: "single_select", options: [{ id: "en", label: "English" }] }];
const completed = [];
await streamDirectCompanion({ ...preparedTurn, turn: { ...preparedTurn.turn, questions: answered } }, {
fetch: async () => new Response('data: {"choices":[{"delta":{"content":"Translated note."}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { headers: { "content-type": "text/event-stream" } }),
onEvent: () => {},
executeTool: async () => { throw new Error("must not execute tools"); },
checkpoint: async () => {},
complete: async body => { completed.push(body); return { turn: { ...preparedTurn.turn, ...body } }; },
});
expect(completed).toHaveLength(1);
expect(completed[0]).toMatchObject({ status: "completed", response: "Translated note." });
});
@@ -250,7 +250,8 @@ export const streamDirectCompanion = async (
throw new Error("No text returned.");
}
const usage = usageFrom(await result.totalUsage);
await finish(pause.ask || questions.length ? "interrupted" : "completed", usage);
// Only a question asked in this run pauses it; older instances still return answered questions on resume.
await finish(pause.ask ? "interrupted" : "completed", usage);
} catch (error) {
if (options.signal?.aborted) {
await finish("interrupted");