From c7d60733c383d1e0a5f048d57f21e764454c2b23 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:31:27 -0700 Subject: [PATCH 1/2] fix(clients): async questions move a thread out of Working Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/state/entities.test.ts | 14 +++----------- packages/client-runtime/src/state/models.ts | 10 ++-------- .../client-runtime/src/state/threadShell.test.ts | 7 +------ packages/shared/src/agentAwareness.test.ts | 9 +++------ packages/shared/src/agentAwareness.ts | 12 ++---------- 5 files changed, 11 insertions(+), 41 deletions(-) diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index abc16b9a7df67..2d0d0922dc1cf 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -22,7 +22,7 @@ import { canSnooze, effectiveSnoozed } from "./threadSettled.ts"; const environmentId = EnvironmentId.make("environment-v2"); describe("V2 client presentation", () => { - it("keeps async questions in Working until the run completes", () => { + it("moves a running thread with an async question out of Working", () => { const runId = RunId.make("run-async-question"); const running = { ...v2ThreadShell, @@ -39,17 +39,9 @@ describe("V2 client presentation", () => { }, }; const active = presentThreadShell(environmentId, running); - expect(active.hasPendingUserInput).toBe(false); - expect(isThreadWorking(active)).toBe(true); + expect(active.hasPendingUserInput).toBe(true); + expect(isThreadWorking(active)).toBe(false); expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); - - const completed = presentThreadShell(environmentId, { - ...running, - activeRunId: null, - status: "completed", - }); - expect(completed.hasPendingUserInput).toBe(true); - expect(isThreadWorking(completed)).toBe(false); expect(effectiveSnoozed(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); }); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 8e04837b8b68c..94c13341f8e01 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -216,15 +216,9 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { ); } -/** An async question waits for an answer only once its run stops working. */ +/** Any open question, async ones included, puts the thread in Input. */ export function shellHasPendingUserInput(thread: OrchestrationV2ThreadShell): boolean { - const status = shellRuntime(thread)?.status; - // Waiting runs have ended their provider turn and are only draining effects. - const working = status === "preparing" || status === "starting" || status === "running"; - return ( - thread.pendingRuntimeRequest?.kind === "user_input" && - (thread.pendingRuntimeRequest.blocking !== false || !working) - ); + return thread.pendingRuntimeRequest?.kind === "user_input"; } // Only work the agent waits on holds it: commands and monitors it left running diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 8807162eb99a0..7eb329320acb3 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -181,7 +181,7 @@ describe("v2 thread shell lists", () => { const disposeNavigation = registry.mount(threads.navigationThreadShellsAtom); const withRequest = ( kind: "user_input" | "command", - options: { readonly blocking?: boolean; readonly settled?: boolean } = {}, + options: { readonly blocking?: boolean } = {}, ) => registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, @@ -189,7 +189,6 @@ describe("v2 thread shell lists", () => { root, { ...sideChat, - ...(options.settled ? { status: "completed" as const, activityRunStatus: null } : {}), pendingRuntimeRequest: { ...request, kind, @@ -206,11 +205,7 @@ describe("v2 thread shell lists", () => { root.id, ]); - // An async question waits while the side chat works, like its own row's indicator. withRequest("user_input", { blocking: false }); - expect(registry.get(parent)?.pendingSubagentRequest).toBeNull(); - expect(registry.get(inputs)).toEqual([]); - withRequest("user_input", { blocking: false, settled: true }); expect(registry.get(parent)?.pendingSubagentRequest).toBe("user_input"); withRequest("command"); diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 3788b7a3e6ba2..d5f77e763622e 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -183,7 +183,7 @@ describe("projectThreadAwarenessV2", () => { ).toMatchObject({ phase }); }); - it("reports an async question as input only after the working run completes", () => { + it("reports an async question as input while the run keeps working", () => { const input = { environmentId: "env-1" as EnvironmentId, project, @@ -197,12 +197,9 @@ describe("projectThreadAwarenessV2", () => { }), }; expect(projectThreadAwarenessV2(input)).toMatchObject({ - phase: "running", - headline: "Agent is working", + phase: "waiting_for_input", + headline: "Waiting for input", }); - expect( - projectThreadAwarenessV2({ ...input, thread: { ...input.thread, status: "completed" } }), - ).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" }); }); it("does not present authentication refreshes as user approvals", () => { diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index e36001ead3d4e..c7bf09fe0dd0b 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -98,22 +98,14 @@ export function projectThreadAwarenessV2( function resolveThreadAwarenessPhaseV2( thread: ProjectThreadAwarenessV2Input["thread"], ): AgentAwarenessPhase | null { - const runStatus = thread.activityRunStatus ?? thread.status; - if ( - thread.pendingRuntimeRequest?.kind === "user_input" && - (thread.pendingRuntimeRequest.blocking !== false || - !(runStatus === "preparing" || runStatus === "starting" || runStatus === "running")) - ) { - return "waiting_for_input"; - } + if (thread.pendingRuntimeRequest?.kind === "user_input") return "waiting_for_input"; if ( thread.pendingRuntimeRequest !== null && - thread.pendingRuntimeRequest.kind !== "user_input" && thread.pendingRuntimeRequest.kind !== "auth_refresh" ) { return "waiting_for_approval"; } - switch (runStatus) { + switch (thread.activityRunStatus ?? thread.status) { case "preparing": case "starting": return "starting"; From ea6a3a1d9bd559bf1bac98dee01f7dc18dfa79c3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:43:20 -0700 Subject: [PATCH 2/2] refactor(client-runtime): drop dead async-question branches Inline the one-line pending input check, simplify the side chat rollup, fix a stale snooze comment, and fold the duplicate Working test. Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/state/entities.test.ts | 8 +++----- packages/client-runtime/src/state/models.ts | 8 ++------ packages/client-runtime/src/state/threadSettled.ts | 2 +- packages/client-runtime/src/state/threadShell.ts | 10 +++------- 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 2d0d0922dc1cf..6cc9bc91595da 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -22,7 +22,7 @@ import { canSnooze, effectiveSnoozed } from "./threadSettled.ts"; const environmentId = EnvironmentId.make("environment-v2"); describe("V2 client presentation", () => { - it("moves a running thread with an async question out of Working", () => { + it("an async question blocks snooze and wakes a snoozed thread", () => { const runId = RunId.make("run-async-question"); const running = { ...v2ThreadShell, @@ -39,14 +39,12 @@ describe("V2 client presentation", () => { }, }; const active = presentThreadShell(environmentId, running); - expect(active.hasPendingUserInput).toBe(true); - expect(isThreadWorking(active)).toBe(false); expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); expect(effectiveSnoozed(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); }); - it("keeps blocking and legacy questions out of Working", () => { - for (const capability of [{ blocking: true }, {}]) { + it("moves a running thread with any question out of Working", () => { + for (const capability of [{ blocking: false }, { blocking: true }, {}]) { const shell = presentThreadShell(environmentId, { ...v2ThreadShell, latestRunId: RunId.make("run-blocking-question"), diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 94c13341f8e01..196b5ff39e171 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -216,11 +216,6 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { ); } -/** Any open question, async ones included, puts the thread in Input. */ -export function shellHasPendingUserInput(thread: OrchestrationV2ThreadShell): boolean { - return thread.pendingRuntimeRequest?.kind === "user_input"; -} - // Only work the agent waits on holds it: commands and monitors it left running // unmarked, such as a dev server, present the run's own status. The // server suppresses the roster while an interruptible activity run exists, so a @@ -315,7 +310,8 @@ export function presentThreadShell( thread.pendingRuntimeRequest !== null && thread.pendingRuntimeRequest.kind !== "user_input" && thread.pendingRuntimeRequest.kind !== "auth_refresh", - hasPendingUserInput: shellHasPendingUserInput(thread), + // Async questions count too, so a thread with any open question leaves Working. + hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input", pendingSubagentRequest, pendingGuestMessageCount: thread.pendingGuestMessageCount ?? 0, hasActionableProposedPlan: thread.hasActionableProposedPlan, diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 340afd95fa5aa..6ad788e0a6623 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -125,7 +125,7 @@ export function canSnooze( options: { readonly now: string }, ): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - // The server rejects snoozing even for nonblocking questions. + // The server rejects snoozing for any pending request. if (shell.source?.pendingRuntimeRequest != null) return false; if (shell.pendingSubagentRequest != null) return false; // A waiting guest message would wake it at once. diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index b7a6eebe571ed..60c454469b551 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -15,7 +15,7 @@ import * as Schema from "effect/Schema"; import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; -import { presentThreadShell, shellHasPendingUserInput, threadRuntimeIsActive } from "./models.ts"; +import { presentThreadShell, threadRuntimeIsActive } from "./models.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { arrayElementsEqual, @@ -84,17 +84,13 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq return kind === "user_input" ? "user_input" : "approval"; } -/** - * A side chat asks its user directly, so questions roll up to the parent as well as approvals, - * when its own row would show them. - */ +/** A side chat asks its user directly, so questions roll up to the parent as well as approvals. */ function sideChatRequest(thread: OrchestrationV2ThreadShell): PendingSubagentRequest | null { const kind = thread.pendingRuntimeRequest?.kind; if (kind === undefined || kind === "auth_refresh" || thread.lineage.sideChat !== true) { return null; } - if (kind !== "user_input") return "approval"; - return shellHasPendingUserInput(thread) ? "user_input" : null; + return kind === "user_input" ? "user_input" : "approval"; } /**