Skip to content

Commit b48b8ea

Browse files
thelogicmatrixclaudejuliusmarminge
authored
fix(web): prompt suggestions show after a steered turn (#33706)
Co-authored-by: Nathan Wong <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Julius Marminge <[email protected]>
1 parent ca796d5 commit b48b8ea

6 files changed

Lines changed: 178 additions & 19 deletions

File tree

‎apps/server/src/orchestration-v2/ProjectionStore.test.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3517,7 +3517,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
35173517
activeAttemptId: null,
35183518
status: "cancelled",
35193519
requestedAt: nowIso,
3520-
startedAt: nowIso,
3520+
startedAt: null,
35213521
completedAt: nowIso,
35223522
checkpointId: null,
35233523
contextHandoffId: null,
@@ -3646,6 +3646,48 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
36463646
),
36473647
);
36483648

3649+
// A steer cancels the queued run before it starts while the steered run keeps writing, so
3650+
// the queued message leaves the window but its run is still the shell's latest run.
3651+
const steeredItemId = "turn-item:bounded-sql-history:steered-output";
3652+
const steeredRunId = RunId.make("run:bounded-sql-history:1000");
3653+
yield* sql`
3654+
INSERT INTO orchestration_v2_projection_turn_items (
3655+
turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id,
3656+
parent_item_id, ordinal, type, status, updated_at, payload_json
3657+
) VALUES (
3658+
${steeredItemId}, ${threadId}, ${steeredRunId}, NULL, NULL, NULL, NULL, 1203,
3659+
'command_execution', 'completed', ${nowIso},
3660+
${encodeUnknownJsonString({
3661+
id: steeredItemId,
3662+
threadId,
3663+
runId: steeredRunId,
3664+
nodeId: null,
3665+
providerThreadId: null,
3666+
providerTurnId: null,
3667+
nativeItemRef: null,
3668+
parentItemId: null,
3669+
ordinal: 1203,
3670+
status: "completed",
3671+
title: "steered output",
3672+
input: "echo steered",
3673+
output: "steered",
3674+
exitCode: 0,
3675+
startedAt: nowIso,
3676+
completedAt: nowIso,
3677+
updatedAt: nowIso,
3678+
type: "command_execution",
3679+
})}
3680+
)
3681+
`;
3682+
const steeredSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, {
3683+
rowLimit: sqlPageLimit,
3684+
});
3685+
assert.isFalse(
3686+
steeredSnapshot.projection.turnItems.some((item) => item.runId === cancelledRunId),
3687+
);
3688+
assert.isTrue(steeredSnapshot.projection.runs.some((run) => run.id === cancelledRunId));
3689+
yield* sql`DELETE FROM orchestration_v2_projection_turn_items WHERE turn_item_id = ${steeredItemId}`;
3690+
36493691
const interruptRunId = RunId.make("run:bounded-sql-history:interrupt");
36503692
const interruptNodeId = NodeId.make("node:bounded-sql-history:interrupt");
36513693
const interruptRequestId = TurnItemId.make("turn-item:bounded-sql-history:interrupt-request");

‎apps/server/src/orchestration-v2/ProjectionStore.ts‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3378,6 +3378,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
33783378
AND (status IN ('queued','preparing','starting','running','waiting')
33793379
OR run_id IN (SELECT value FROM json_each(${cohortRunIds}))
33803380
OR run_id = ${window.requiredRunId ?? null}
3381+
${
3382+
// The shell's latest run, even a steer-cancelled one outside the newest window.
3383+
window.anchorItemId === undefined && window.requiredRunId === undefined
3384+
? sql`OR run_id = ${presentedShellRunId(sql`${threadId}`)}`
3385+
: sql``
3386+
}
33813387
-- Queued or cancelled turns must not hide the latest actual session.
33823388
OR run_id = ${latestTodoList?.run_id ?? null}
33833389
OR run_id = (
@@ -6192,6 +6198,19 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
61926198
)
61936199
)`;
61946200
};
6201+
// The newest run not waiting in a held queue, matching latestUnheldRun.
6202+
const presentedShellRunId = (threadId: Statement.Fragment) => sql`(
6203+
SELECT candidate.run_id
6204+
FROM orchestration_v2_projection_runs candidate
6205+
WHERE candidate.thread_id = ${threadId}
6206+
AND NOT (
6207+
candidate.status = 'queued'
6208+
AND json_extract(candidate.payload_json, '$.queueHeld') IS 1
6209+
)
6210+
AND NOT ${isSilentShellRun("candidate")}
6211+
ORDER BY candidate.ordinal DESC, candidate.run_id DESC
6212+
LIMIT 1
6213+
)`;
61956214

61966215
const selectShellThreadRows = (
61976216
threadId?: ThreadId,
@@ -6359,19 +6378,8 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
63596378
ORDER BY r.ordinal DESC, r.run_id DESC
63606379
LIMIT 1
63616380
)
6362-
-- The newest run not waiting in a held queue, matching latestUnheldRun.
6363-
LEFT JOIN orchestration_v2_projection_runs presented ON presented.run_id = (
6364-
SELECT candidate.run_id
6365-
FROM orchestration_v2_projection_runs candidate
6366-
WHERE candidate.thread_id = t.thread_id
6367-
AND NOT (
6368-
candidate.status = 'queued'
6369-
AND json_extract(candidate.payload_json, '$.queueHeld') IS 1
6370-
)
6371-
AND NOT ${isSilentShellRun("candidate")}
6372-
ORDER BY candidate.ordinal DESC, candidate.run_id DESC
6373-
LIMIT 1
6374-
)
6381+
LEFT JOIN orchestration_v2_projection_runs presented
6382+
ON presented.run_id = ${presentedShellRunId(sql.literal("t.thread_id"))}
63756383
LEFT JOIN orchestration_v2_projection_runs blocked ON blocked.run_id = (
63766384
SELECT candidate.run_id
63776385
FROM orchestration_v2_projection_runs candidate

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplat
380380
const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray<CodexFeedbackSubmission> = [];
381381
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
382382
const EMPTY_TURN_ITEMS: ThreadStructure["turnItems"] = [];
383+
const EMPTY_RUNS: ThreadStructure["runs"] = [];
383384
const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray<EnvironmentPresentation> = [];
384385
const EMPTY_COMPOSER_BANNER_ITEMS: ReadonlyArray<ComposerBannerStackItem> = [];
385386

@@ -3575,6 +3576,7 @@ export default function ChatView(props: ChatViewProps) {
35753576
draftId={draftId}
35763577
activeThreadId={activeThreadId}
35773578
activeThread={activeThread}
3579+
activeThreadRuns={serverProjection?.runs ?? EMPTY_RUNS}
35783580
promptHistoryEntries={timelineEntries}
35793581
forceExpandedOnMobile={
35803582
forceExpandedMobileComposer && isDraftHeroState

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,11 @@ import { useThreadContextDrop, threadContextDropTargetProps } from "./threadCont
187187
import { readThreadShell } from "~/state/entities";
188188
import { useLinkedEnvironments } from "~/state/peerLinks";
189189
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
190-
import type { ComposerContextRecord, ThreadPullRequestLink } from "@t3tools/contracts";
190+
import type {
191+
ComposerContextRecord,
192+
OrchestrationV2Run,
193+
ThreadPullRequestLink,
194+
} from "@t3tools/contracts";
191195
import { threadContextAttachment } from "~/lib/threadContextAttachment";
192196
import {
193197
type ComposerCommandItem,
@@ -353,6 +357,7 @@ export interface ChatComposerProps {
353357

354358
activeThreadId: ThreadId | null;
355359
activeThread: Thread | undefined;
360+
activeThreadRuns: ReadonlyArray<OrchestrationV2Run>;
356361
promptHistoryEntries: ReadonlyArray<TimelineEntry>;
357362
forceExpandedOnMobile: boolean;
358363
projectSelectionRequired: boolean;
@@ -499,6 +504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
499504
onMultipleModelSelectionsChange: setMultipleModelSelections,
500505
activeThreadId,
501506
activeThread,
507+
activeThreadRuns,
502508
promptHistoryEntries,
503509
forceExpandedOnMobile,
504510
projectSelectionRequired,
@@ -2258,6 +2264,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22582264
const { suggestion: promptSuggestion, dismiss: dismissPromptSuggestion } = usePromptSuggestion(
22592265
environmentId,
22602266
activeThread,
2267+
activeThreadRuns,
22612268
);
22622269
// After /clear the next message starts a fresh session that cannot see what the suggestion refers to.
22632270
const freshSessionArmed = useFreshSessionArmed(scopedThreadKey(routeThreadRef));
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
MessageId,
3+
type OrchestrationV2Run,
4+
ProviderInstanceId,
5+
RunId,
6+
ThreadId,
7+
} from "@t3tools/contracts";
8+
import * as DateTime from "effect/DateTime";
9+
import { describe, expect, it } from "vite-plus/test";
10+
11+
import { offeredPromptSuggestion } from "./usePromptSuggestion";
12+
13+
const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
14+
const run = (
15+
id: RunId,
16+
ordinal: number,
17+
status: OrchestrationV2Run["status"],
18+
started = true,
19+
): OrchestrationV2Run => ({
20+
id,
21+
threadId: ThreadId.make("thread-1"),
22+
ordinal,
23+
providerInstanceId: ProviderInstanceId.make("claudeAgent"),
24+
modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-haiku-4-5" },
25+
providerThreadId: null,
26+
userMessageId: MessageId.make(`message-${ordinal}`),
27+
rootNodeId: null,
28+
activeAttemptId: null,
29+
status,
30+
requestedAt: now,
31+
startedAt: started ? now : null,
32+
completedAt: now,
33+
checkpointId: null,
34+
contextHandoffId: null,
35+
});
36+
const summary = (value: OrchestrationV2Run) => ({
37+
runId: value.id,
38+
status: value.status,
39+
requestedAt: null,
40+
startedAt: value.startedAt === null ? null : "2026-06-20T00:00:00.000Z",
41+
completedAt: "2026-06-20T00:00:00.000Z",
42+
assistantMessageId: null,
43+
});
44+
45+
describe("offeredPromptSuggestion", () => {
46+
it("offers the finished turn's suggestion after a queued message was promoted to Steer", () => {
47+
const turn = run(RunId.make("run-1"), 1, "completed");
48+
const steered = run(RunId.make("run-2"), 2, "cancelled", false);
49+
const suggestion = { runId: turn.id, text: "run the tests" };
50+
51+
expect(offeredPromptSuggestion(suggestion, summary(steered), [turn, steered])).toBe(suggestion);
52+
53+
// A later run that did start still replaces the earlier turn's suggestion.
54+
const stopped = run(RunId.make("run-2"), 2, "cancelled");
55+
expect(offeredPromptSuggestion(suggestion, summary(stopped), [turn, stopped])).toBeNull();
56+
57+
// Several never-started runs in a row still fall back to the turn that ran.
58+
const removed = run(RunId.make("run-3"), 3, "cancelled", false);
59+
expect(offeredPromptSuggestion(suggestion, summary(removed), [turn, steered, removed])).toBe(
60+
suggestion,
61+
);
62+
63+
// A newer turn that completed behind the cancelled run owns the suggestion slot.
64+
const next = run(RunId.make("run-2"), 2, "completed");
65+
expect(offeredPromptSuggestion(suggestion, summary(removed), [turn, next, removed])).toBeNull();
66+
67+
// Cached detail that predates the shell's cancelled run cannot vouch for what ran in between.
68+
const unseen = run(RunId.make("run-3"), 3, "cancelled", false);
69+
expect(offeredPromptSuggestion(suggestion, summary(unseen), [turn])).toBeNull();
70+
});
71+
});

‎apps/web/src/components/chat/usePromptSuggestion.ts‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import type { EnvironmentId, RunId } from "@t3tools/contracts";
1+
import type { EnvironmentId, OrchestrationV2Run, RunId } from "@t3tools/contracts";
22
import { useSyncExternalStore } from "react";
33

44
import type { Thread } from "../../types";
55
import { useMediaQuery } from "../../hooks/useMediaQuery";
66
import { useServerConfigs } from "../../state/entities";
77
import { orchestrationEnvironment } from "../../state/orchestration";
88
import { useEnvironmentQuery } from "../../state/query";
9+
import { announcedRun } from "./threadActivityAnnouncement";
910

1011
// Dismissed suggestions, keyed by environment and run, shared by every mounted composer and kept
1112
// across remounts. Run ids derive from thread ids, which can repeat across environments.
@@ -18,13 +19,43 @@ const subscribeDismissed = (listener: () => void) => {
1819
const readDismissed = () => dismissedKeys;
1920
const dismissKey = (environmentId: EnvironmentId, runId: RunId) => `${environmentId}\u0000${runId}`;
2021

22+
/**
23+
* The suggestion while the run that produced it is the thread's latest completed run. A message
24+
* promoted to Steer leaves its queued run cancelled before it started, still the latest run, so
25+
* the comparison skips to the last run that ran. Detail can lag the shell, so it skips only once
26+
* the runs include that cancelled run.
27+
*/
28+
export function offeredPromptSuggestion<Suggestion extends { readonly runId: RunId }>(
29+
suggestion: Suggestion | null | undefined,
30+
latestRun: Thread["latestRun"] | undefined,
31+
runs: ReadonlyArray<OrchestrationV2Run>,
32+
): Suggestion | null {
33+
const run = announcedRun(latestRun ?? null, runs);
34+
const skippedUnseenRun =
35+
latestRun != null &&
36+
run?.runId !== latestRun.runId &&
37+
!runs.some(
38+
(candidate) =>
39+
candidate.id === latestRun.runId &&
40+
candidate.status === "cancelled" &&
41+
candidate.startedAt === null,
42+
);
43+
return suggestion &&
44+
!skippedUnseenRun &&
45+
run?.runId === suggestion.runId &&
46+
run.status === "completed"
47+
? suggestion
48+
: null;
49+
}
50+
2151
/**
2252
* The provider's predicted next prompt, while the run that produced it is the thread's latest
2353
* completed run and Escape has not dismissed it.
2454
*/
2555
export function usePromptSuggestion(
2656
environmentId: EnvironmentId,
2757
thread: Thread | undefined,
58+
runs: ReadonlyArray<OrchestrationV2Run>,
2859
): { readonly suggestion: string | null; readonly dismiss: () => void } {
2960
// Older servers decode the missing setting as false, so true also proves the method exists.
3061
const enabled = useServerConfigs().get(environmentId)?.settings.promptSuggestionsEnabled === true;
@@ -40,9 +71,7 @@ export function usePromptSuggestion(
4071
: null,
4172
);
4273
const dismissed = useSyncExternalStore(subscribeDismissed, readDismissed);
43-
const latestRun = thread?.latestRun;
44-
const offered =
45-
data && latestRun?.runId === data.runId && latestRun.status === "completed" ? data : null;
74+
const offered = offeredPromptSuggestion(data, thread?.latestRun, runs);
4675
return {
4776
suggestion:
4877
offered && !dismissed.has(dismissKey(environmentId, offered.runId)) ? offered.text : null,

0 commit comments

Comments
 (0)