Skip to content

Commit 955e2bf

Browse files
t3dotggclaude
andauthored
fix(clients): questions always move a thread out of Working (pingdotgg#28515)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent ce48e45 commit 955e2bf

7 files changed

Lines changed: 15 additions & 55 deletions

File tree

‎packages/client-runtime/src/state/entities.test.ts‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { canSnooze, effectiveSnoozed } from "./threadSettled.ts";
2222
const environmentId = EnvironmentId.make("environment-v2");
2323

2424
describe("V2 client presentation", () => {
25-
it("keeps async questions in Working until the run completes", () => {
25+
it("an async question blocks snooze and wakes a snoozed thread", () => {
2626
const runId = RunId.make("run-async-question");
2727
const running = {
2828
...v2ThreadShell,
@@ -39,22 +39,12 @@ describe("V2 client presentation", () => {
3939
},
4040
};
4141
const active = presentThreadShell(environmentId, running);
42-
expect(active.hasPendingUserInput).toBe(false);
43-
expect(isThreadWorking(active)).toBe(true);
4442
expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false);
45-
46-
const completed = presentThreadShell(environmentId, {
47-
...running,
48-
activeRunId: null,
49-
status: "completed",
50-
});
51-
expect(completed.hasPendingUserInput).toBe(true);
52-
expect(isThreadWorking(completed)).toBe(false);
5343
expect(effectiveSnoozed(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false);
5444
});
5545

56-
it("keeps blocking and legacy questions out of Working", () => {
57-
for (const capability of [{ blocking: true }, {}]) {
46+
it("moves a running thread with any question out of Working", () => {
47+
for (const capability of [{ blocking: false }, { blocking: true }, {}]) {
5848
const shell = presentThreadShell(environmentId, {
5949
...v2ThreadShell,
6050
latestRunId: RunId.make("run-blocking-question"),

‎packages/client-runtime/src/state/models.ts‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -216,17 +216,6 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean {
216216
);
217217
}
218218

219-
/** An async question waits for an answer only once its run stops working. */
220-
export function shellHasPendingUserInput(thread: OrchestrationV2ThreadShell): boolean {
221-
const status = shellRuntime(thread)?.status;
222-
// Waiting runs have ended their provider turn and are only draining effects.
223-
const working = status === "preparing" || status === "starting" || status === "running";
224-
return (
225-
thread.pendingRuntimeRequest?.kind === "user_input" &&
226-
(thread.pendingRuntimeRequest.blocking !== false || !working)
227-
);
228-
}
229-
230219
// Only work the agent waits on holds it: commands and monitors it left running
231220
// unmarked, such as a dev server, present the run's own status. The
232221
// server suppresses the roster while an interruptible activity run exists, so a
@@ -321,7 +310,8 @@ export function presentThreadShell(
321310
thread.pendingRuntimeRequest !== null &&
322311
thread.pendingRuntimeRequest.kind !== "user_input" &&
323312
thread.pendingRuntimeRequest.kind !== "auth_refresh",
324-
hasPendingUserInput: shellHasPendingUserInput(thread),
313+
// Async questions count too, so a thread with any open question leaves Working.
314+
hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input",
325315
pendingSubagentRequest,
326316
pendingGuestMessageCount: thread.pendingGuestMessageCount ?? 0,
327317
hasActionableProposedPlan: thread.hasActionableProposedPlan,

‎packages/client-runtime/src/state/threadSettled.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export function canSnooze(
125125
options: { readonly now: string },
126126
): boolean {
127127
if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;
128-
// The server rejects snoozing even for nonblocking questions.
128+
// The server rejects snoozing for any pending request.
129129
if (shell.source?.pendingRuntimeRequest != null) return false;
130130
if (shell.pendingSubagentRequest != null) return false;
131131
// A waiting guest message would wake it at once.

‎packages/client-runtime/src/state/threadShell.test.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,14 @@ describe("v2 thread shell lists", () => {
181181
const disposeNavigation = registry.mount(threads.navigationThreadShellsAtom);
182182
const withRequest = (
183183
kind: "user_input" | "command",
184-
options: { readonly blocking?: boolean; readonly settled?: boolean } = {},
184+
options: { readonly blocking?: boolean } = {},
185185
) =>
186186
registry.set(snapshotAtom(environmentId), {
187187
...v2ShellSnapshot,
188188
threads: [
189189
root,
190190
{
191191
...sideChat,
192-
...(options.settled ? { status: "completed" as const, activityRunStatus: null } : {}),
193192
pendingRuntimeRequest: {
194193
...request,
195194
kind,
@@ -206,11 +205,7 @@ describe("v2 thread shell lists", () => {
206205
root.id,
207206
]);
208207

209-
// An async question waits while the side chat works, like its own row's indicator.
210208
withRequest("user_input", { blocking: false });
211-
expect(registry.get(parent)?.pendingSubagentRequest).toBeNull();
212-
expect(registry.get(inputs)).toEqual([]);
213-
withRequest("user_input", { blocking: false, settled: true });
214209
expect(registry.get(parent)?.pendingSubagentRequest).toBe("user_input");
215210

216211
withRequest("command");

‎packages/client-runtime/src/state/threadShell.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import * as Schema from "effect/Schema";
1515
import { Atom } from "effect/reactivity";
1616

1717
import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts";
18-
import { presentThreadShell, shellHasPendingUserInput, threadRuntimeIsActive } from "./models.ts";
18+
import { presentThreadShell, threadRuntimeIsActive } from "./models.ts";
1919
import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
2020
import {
2121
arrayElementsEqual,
@@ -84,17 +84,13 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq
8484
return kind === "user_input" ? "user_input" : "approval";
8585
}
8686

87-
/**
88-
* A side chat asks its user directly, so questions roll up to the parent as well as approvals,
89-
* when its own row would show them.
90-
*/
87+
/** A side chat asks its user directly, so questions roll up to the parent as well as approvals. */
9188
function sideChatRequest(thread: OrchestrationV2ThreadShell): PendingSubagentRequest | null {
9289
const kind = thread.pendingRuntimeRequest?.kind;
9390
if (kind === undefined || kind === "auth_refresh" || thread.lineage.sideChat !== true) {
9491
return null;
9592
}
96-
if (kind !== "user_input") return "approval";
97-
return shellHasPendingUserInput(thread) ? "user_input" : null;
93+
return kind === "user_input" ? "user_input" : "approval";
9894
}
9995

10096
/**

‎packages/shared/src/agentAwareness.test.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ describe("projectThreadAwarenessV2", () => {
183183
).toMatchObject({ phase });
184184
});
185185

186-
it("reports an async question as input only after the working run completes", () => {
186+
it("reports an async question as input while the run keeps working", () => {
187187
const input = {
188188
environmentId: "env-1" as EnvironmentId,
189189
project,
@@ -197,12 +197,9 @@ describe("projectThreadAwarenessV2", () => {
197197
}),
198198
};
199199
expect(projectThreadAwarenessV2(input)).toMatchObject({
200-
phase: "running",
201-
headline: "Agent is working",
200+
phase: "waiting_for_input",
201+
headline: "Waiting for input",
202202
});
203-
expect(
204-
projectThreadAwarenessV2({ ...input, thread: { ...input.thread, status: "completed" } }),
205-
).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" });
206203
});
207204

208205
it("does not present authentication refreshes as user approvals", () => {

‎packages/shared/src/agentAwareness.ts‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,22 +98,14 @@ export function projectThreadAwarenessV2(
9898
function resolveThreadAwarenessPhaseV2(
9999
thread: ProjectThreadAwarenessV2Input["thread"],
100100
): AgentAwarenessPhase | null {
101-
const runStatus = thread.activityRunStatus ?? thread.status;
102-
if (
103-
thread.pendingRuntimeRequest?.kind === "user_input" &&
104-
(thread.pendingRuntimeRequest.blocking !== false ||
105-
!(runStatus === "preparing" || runStatus === "starting" || runStatus === "running"))
106-
) {
107-
return "waiting_for_input";
108-
}
101+
if (thread.pendingRuntimeRequest?.kind === "user_input") return "waiting_for_input";
109102
if (
110103
thread.pendingRuntimeRequest !== null &&
111-
thread.pendingRuntimeRequest.kind !== "user_input" &&
112104
thread.pendingRuntimeRequest.kind !== "auth_refresh"
113105
) {
114106
return "waiting_for_approval";
115107
}
116-
switch (runStatus) {
108+
switch (thread.activityRunStatus ?? thread.status) {
117109
case "preparing":
118110
case "starting":
119111
return "starting";

0 commit comments

Comments
 (0)