diff --git a/apps/api/src/companion-service.ts b/apps/api/src/companion-service.ts index f33b7181..67b5685f 100644 --- a/apps/api/src/companion-service.ts +++ b/apps/api/src/companion-service.ts @@ -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); diff --git a/apps/api/src/companion.test.mjs b/apps/api/src/companion.test.mjs index 45a01bfa..5931fe37 100644 --- a/apps/api/src/companion.test.mjs +++ b/apps/api/src/companion.test.mjs @@ -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", () => { diff --git a/packages/client/src/companion-direct-stream.test.mjs b/packages/client/src/companion-direct-stream.test.mjs index 47552c03..06401cfc 100644 --- a/packages/client/src/companion-direct-stream.test.mjs +++ b/packages/client/src/companion-direct-stream.test.mjs @@ -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." }); +}); diff --git a/packages/client/src/companion-direct-stream.ts b/packages/client/src/companion-direct-stream.ts index 2f51dee5..b502367a 100644 --- a/packages/client/src/companion-direct-stream.ts +++ b/packages/client/src/companion-direct-stream.ts @@ -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");