From 7e56921e4997a482bf3cda468226ad0acd6dc314 Mon Sep 17 00:00:00 2001 From: Tim Dogan Date: Fri, 6 Nov 2026 12:47:10 +1100 Subject: [PATCH] fix(muse): questions Muse cancels or times out no longer show as answered Co-Authored-By: Claude Opus 5.5 --- .../provider-muse/src/server/adapter.test.ts | 42 +++++++++++++++++++ packages/provider-muse/src/server/adapter.ts | 13 +++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 847c2b4551c59..3234e4650c9f8 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -1393,6 +1393,48 @@ describe("MuseAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect.each(["cancelled", "interrupted", "timedOut", "aborted"] as const)( + "cancels a question Muse settles as %s without an answer", + (outcome) => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("userInput/requested", { + userInputId: "question-1", + turnId: nativeId, + questions: [ + { + id: "approach", + header: "Approach", + question: "Which approach?", + options: [{ label: "Small" }, { label: "Large" }], + selection: { mode: "single" }, + }, + ], + }); + yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.kind === "user_input", + ); + yield* fake.emit("userInput/settled", { + userInputId: "question-1", + turnId: nativeId, + answers: [], + clarification: null, + outcome, + }); + const settled = yield* harness.takeEvent("runtime_request.updated"); + assert.strictEqual(settled.runtimeRequest.status, "cancelled"); + assert.isUndefined(settled.runtimeRequest.answers); + const item = yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.type === "user_input_request", + ); + assert.strictEqual(item.turnItem.status, "cancelled"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("steers only the active native turn and waits for confirmed interruption", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index b29f8d51947bf..1d2823e288d87 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -261,6 +261,12 @@ const INFORMATIONAL_NOTIFICATIONS = new Set([ "usage/changed", ]); const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "workflow"]); +const UNANSWERED_QUESTION_OUTCOMES = new Set([ + "cancelled", + "interrupted", + "timedOut", + "aborted", +]); const responseAnswerSchema = Schema.Union([Schema.String, Schema.Array(Schema.String)]); export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( @@ -1300,7 +1306,12 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( ? museApprovalDecision({ decision: params.decision, scope: "once" }) : undefined; if (!entry.response && native) entry.response = { decision: native }; - yield* resolvePending(entry, "resolved"); + yield* resolvePending( + entry, + method === "userInput/settled" && UNANSWERED_QUESTION_OUTCOMES.has(params.outcome) + ? "cancelled" + : "resolved", + ); } if (![...pending.values()].some((candidate) => candidate.shown)) yield* updateSession(active ? "running" : "ready");