Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,7 @@ export function threadShellFromProjection(
id: pendingRuntimeRequest.id,
kind: pendingRuntimeRequest.kind,
createdAt: pendingRuntimeRequest.createdAt,
blocking: pendingRuntimeRequest.responseCapability.type !== "message",
Comment thread
Gigioxx marked this conversation as resolved.
Outdated
},
// Thread detail owns message bodies. Keeping them out of shell rows makes
// initial hydration and streaming updates independent of transcript size.
Expand Down Expand Up @@ -1622,6 +1623,7 @@ function shellFromState(input: {
id: input.state.pendingRuntimeRequest.id,
kind: input.state.pendingRuntimeRequest.kind,
createdAt: input.state.pendingRuntimeRequest.createdAt,
blocking: input.state.pendingRuntimeRequest.responseCapability.type !== "message",
},
latestVisibleMessage: null,
latestUserMessageAt: input.state.latestUserMessageAt,
Expand Down
34 changes: 34 additions & 0 deletions packages/client-runtime/src/state/entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,40 @@ describe("V2 client presentation", () => {
});
});

it("lets only blocking questions mark a running thread as awaiting input", () => {
const runId = RunId.make("run-async-question");
const running = {
...v2ThreadShell,
latestRunId: runId,
activeRunId: runId,
status: "running" as const,
};
const question = {
id: RuntimeRequestId.make("request-async-question"),
kind: "user_input" as const,
createdAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
};

const presentInput = (shell: typeof v2ThreadShell) =>
presentThreadShell(environmentId, shell).hasPendingUserInput;

expect(
presentInput({ ...running, pendingRuntimeRequest: { ...question, blocking: false } }),
).toBe(false);
expect(
presentInput({ ...running, pendingRuntimeRequest: { ...question, blocking: true } }),
).toBe(true);
expect(presentInput({ ...running, pendingRuntimeRequest: question })).toBe(true);
expect(
presentInput({
...running,
activeRunId: null,
status: "completed",
pendingRuntimeRequest: { ...question, blocking: false },
}),
).toBe(true);
});

it("preserves shell run timestamps for sidebar activity clocks", () => {
const runId = RunId.make("run-working-clock");
const requestedAt = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
Expand Down
13 changes: 11 additions & 2 deletions packages/client-runtime/src/state/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ export function presentThreadShell(
: nullableIso(thread.latestRunCompletedAt),
assistantMessageId: null,
} satisfies ThreadRunSummary);
const runtime = shellRuntime(thread);
// Async questions stay open while the run keeps working; they only read as
// input once the run stops.
const runtimeIsActive =
runtime?.status === "preparing" ||
runtime?.status === "starting" ||
runtime?.status === "running";
return {
environmentId,
id: thread.id,
Expand All @@ -239,13 +246,15 @@ export function presentThreadShell(
forkedFrom: thread.forkedFrom,
activeProviderThreadId: thread.activeProviderThreadId,
latestRun,
runtime: shellRuntime(thread),
runtime,
latestUserMessageAt: nullableIso(thread.latestUserMessageAt),
hasPendingApprovals:
thread.pendingRuntimeRequest !== null &&
thread.pendingRuntimeRequest.kind !== "user_input" &&
thread.pendingRuntimeRequest.kind !== "auth_refresh",
hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input",
hasPendingUserInput:
thread.pendingRuntimeRequest?.kind === "user_input" &&
(thread.pendingRuntimeRequest.blocking !== false || !runtimeIsActive),
hasActionableProposedPlan: thread.hasActionableProposedPlan,
pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],
providerInstanceHistory: thread.providerInstanceHistory ?? [],
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,8 @@ export const OrchestrationV2PendingRuntimeRequestSummary = Schema.Struct({
id: RuntimeRequestId,
kind: OrchestrationV2RuntimeRequest.fields.kind,
createdAt: Schema.DateTimeUtc,
/** False for async questions the run keeps working past. Absent from older servers. */
blocking: Schema.optional(Schema.Boolean),
});
export type OrchestrationV2PendingRuntimeRequestSummary =
typeof OrchestrationV2PendingRuntimeRequestSummary.Type;
Expand Down
18 changes: 18 additions & 0 deletions packages/shared/src/agentAwareness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,24 @@ describe("projectThreadAwarenessV2", () => {
).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" });
});

it("keeps a running thread with an async question running", () => {
expect(
projectThreadAwarenessV2({
environmentId: "env-1" as EnvironmentId,
project,
thread: v2Thread({
status: "running",
pendingRuntimeRequest: {
id: RuntimeRequestId.make("request-async"),
kind: "user_input",
createdAt: updatedAt,
blocking: false,
},
}),
}),
).toMatchObject({ phase: "running" });
});

it("does not present authentication refreshes as user approvals", () => {
expect(
projectThreadAwarenessV2({
Expand Down
11 changes: 9 additions & 2 deletions packages/shared/src/agentAwareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,23 @@ export function projectThreadAwarenessV2(
function resolveThreadAwarenessPhaseV2(
thread: ProjectThreadAwarenessV2Input["thread"],
): AgentAwarenessPhase | null {
if (thread.pendingRuntimeRequest?.kind === "user_input") {
const runStatus = thread.activityRunStatus ?? thread.status;
if (
thread.pendingRuntimeRequest?.kind === "user_input" &&
// Async questions do not block a run that is still working.
(thread.pendingRuntimeRequest.blocking !== false ||
!(runStatus === "preparing" || runStatus === "starting" || runStatus === "running"))
) {
return "waiting_for_input";
}
if (
thread.pendingRuntimeRequest !== null &&
thread.pendingRuntimeRequest.kind !== "user_input" &&
thread.pendingRuntimeRequest.kind !== "auth_refresh"
) {
return "waiting_for_approval";
}
switch (thread.activityRunStatus ?? thread.status) {
switch (runStatus) {
case "preparing":
case "starting":
return "starting";
Expand Down
Loading