authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 14:12:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 14:12:29-07:00
loga172e1c4e8098c7d8dbe538a375ecc69faf9514d
tree2e9f7b9635bc0591dfce1d574a0df95098048fa6
parentf32c23cf1aa9ff177224e93c239165adc67798c0
signature Signed by PGP key B5690EEEBB952194

feat: native /goal for Codex and Claude, with goal status in the UI (#15592)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

26 files changed, 2322 insertions(+), 200 deletions(-)

apps/mobile/src/components/AppSymbol.tsx+2
...@@ -98,6 +98,7 @@ import IconStar from "@tabler/icons-react-native/IconStar";...@@ -98,6 +98,7 @@ import IconStar from "@tabler/icons-react-native/IconStar";
98import IconStarFilled from "@tabler/icons-react-native/IconStarFilled";98import IconStarFilled from "@tabler/icons-react-native/IconStarFilled";
99import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";99import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";
100import IconSun from "@tabler/icons-react-native/IconSun";100import IconSun from "@tabler/icons-react-native/IconSun";
101import IconTarget from "@tabler/icons-react-native/IconTarget";
101import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";102import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
102import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";103import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
103import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease";104import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease";
...@@ -216,6 +217,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -216,6 +217,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
216 "star.fill": IconStarFilled,217 "star.fill": IconStarFilled,
217 "sun.max": IconSun,218 "sun.max": IconSun,
218 "stop.fill": IconPlayerStopFilled,219 "stop.fill": IconPlayerStopFilled,
220 target: IconTarget,
219 terminal: IconTerminal2,221 terminal: IconTerminal2,
220 "text.alignleft": IconAlignLeft,222 "text.alignleft": IconAlignLeft,
221 "text.bubble": IconMessage,223 "text.bubble": IconMessage,
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+12-1
...@@ -31,7 +31,10 @@ import {...@@ -31,7 +31,10 @@ import {
31 type CodexArtifactTemplate,31 type CodexArtifactTemplate,
32} from "@t3tools/client-runtime/codex-artifact-templates";32} from "@t3tools/client-runtime/codex-artifact-templates";
33import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";33import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
34import { presentPendingBackgroundWork } from "@t3tools/client-runtime/state/thread-execution";34import {
35 presentPendingBackgroundWork,
36 presentProviderGoal,
37} from "@t3tools/client-runtime/state/thread-execution";
35import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";38import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
36import {39import {
37 formatModelSelectionEffort,40 formatModelSelectionEffort,
...@@ -491,6 +494,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -491,6 +494,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
491 waiting: pendingBackgroundWork.waiting,494 waiting: pendingBackgroundWork.waiting,
492 };495 };
493 }496 }
497 if (props.selectedThread.goal !== null && contentPresentationKind === "ready") {
498 const goal = presentProviderGoal(props.selectedThread.goal, false);
499 return {
500 kind: "goal",
501 label: goal.title,
502 accessibilityLabel: `${goal.title}: ${goal.objective}`,
503 };
504 }
494 return null;505 return null;
495 })();506 })();
496 const showWorkingControl = floatingStatus !== null;507 const showWorkingControl = floatingStatus !== null;
apps/mobile/src/features/threads/floating-working-control.tsx+15
...@@ -407,6 +407,21 @@ function FloatingStatusLabel(props: {...@@ -407,6 +407,21 @@ function FloatingStatusLabel(props: {
407 </StatusLabelRow>407 </StatusLabelRow>
408 );408 );
409 }409 }
410 if (props.status.kind === "goal") {
411 return (
412 <StatusLabelRow
413 key="goal"
414 accessibilityLabel={props.status.accessibilityLabel}
415 className="gap-2"
416 onLayout={props.onLayout}
417 >
418 <SymbolView name="target" size={13} tintColorClassName="foreground" type="monochrome" />
419 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
420 {props.status.label}
421 </Text>
422 </StatusLabelRow>
423 );
424 }
410 if (props.status.kind === "preparing") {425 if (props.status.kind === "preparing") {
411 return (426 return (
412 <StatusLabelRow427 <StatusLabelRow
apps/mobile/src/features/threads/floating-working-status.ts+2
...@@ -17,6 +17,8 @@ export type FloatingWorkingStatus =...@@ -17,6 +17,8 @@ export type FloatingWorkingStatus =
17 readonly accessibilityLabel: string;17 readonly accessibilityLabel: string;
18 readonly waiting: boolean;18 readonly waiting: boolean;
19 }19 }
20 // A native /goal on an idle thread: paused, blocked, complete, or set.
21 | { readonly kind: "goal"; readonly label: string; readonly accessibilityLabel: string }
20 // A task whose thread the server has not created yet: the worktree may22 // A task whose thread the server has not created yet: the worktree may
21 // still be checking out, so there is no turn to time.23 // still be checking out, so there is no turn to time.
22 | { readonly kind: "preparing"; readonly label: string }24 | { readonly kind: "preparing"; readonly label: string }
apps/mobile/src/features/threads/thread-list-v2-items.tsx+5-1
...@@ -587,8 +587,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -587,8 +587,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
587 // label as the web sidebar, sourced from the server-side visited watermark587 // label as the web sidebar, sourced from the server-side visited watermark
588 // so checking a thread on any device clears it everywhere.588 // so checking a thread on any device clears it everywhere.
589 const isUnread = status === "ready" && threadHasUnseenCompletion(thread);589 const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
590 const workingLabel = STATUS_LABEL_BY_STATUS[status];
590 const statusLabel =591 const statusLabel =
591 STATUS_LABEL_BY_STATUS[status] ??592 // A native /goal keeps the agent going across turns until it is met.
593 (status === "working" && workingLabel !== undefined && thread.goal?.status === "active"
594 ? { ...workingLabel, label: "Goal" }
595 : workingLabel) ??
592 (isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined);596 (isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined);
593 // The timestamp is precomputed on the list item (same stamps the settled597 // The timestamp is precomputed on the list item (same stamps the settled
594 // tail sorts by) so a minute tick only re-renders rows that draw it.598 // tail sorts by) so a minute tick only re-renders rows that draw it.
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+149
...@@ -8172,6 +8172,155 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8172,6 +8172,155 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8172 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),8172 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
8173 ),8173 ),
8174 );8174 );
8175
8176 describe("native goals", () => {
8177 const syntheticFrame = (uuid: string, text: string) =>
8178 claudeSdkFrame({
8179 type: "assistant",
8180 message: {
8181 model: "<synthetic>",
8182 id: uuid,
8183 type: "message",
8184 role: "assistant",
8185 content: [{ type: "text", text }],
8186 stop_reason: "end_turn",
8187 stop_sequence: null,
8188 usage: {
8189 input_tokens: 0,
8190 output_tokens: 0,
8191 cache_creation_input_tokens: 0,
8192 cache_read_input_tokens: 0,
8193 },
8194 },
8195 parent_tool_use_id: null,
8196 uuid,
8197 session_id: WAKE_NATIVE_SESSION,
8198 });
8199 const stopHookFeedback = (uuid: string, condition: string, reason: string) =>
8200 claudeSdkFrame({
8201 type: "user",
8202 message: {
8203 role: "user",
8204 content: [{ type: "text", text: `Stop hook feedback:\n[${condition}]: ${reason}` }],
8205 },
8206 parent_tool_use_id: null,
8207 isSynthetic: true,
8208 uuid,
8209 session_id: WAKE_NATIVE_SESSION,
8210 });
8211 const goalStatuses = (events: ReadonlyArray<ProviderAdapterV2Event>) =>
8212 events.flatMap((event) =>
8213 event.type === "provider_thread.updated" && event.providerThread.goal != null
8214 ? [event.providerThread.goal]
8215 : [],
8216 );
8217
8218 it.effect("tracks a /goal through unmet checks until Claude stops on its own", () =>
8219 Effect.gen(function* () {
8220 const harness = yield* makeWakeHarness;
8221 const condition = "all tests pass";
8222 yield* harness.runtime.startTurn(
8223 makeClaudeTestTurnInput({
8224 threadId: harness.threadId,
8225 providerThread: harness.providerThread,
8226 now: yield* DateTime.now,
8227 attemptId: RunAttemptId.make("goal-attempt"),
8228 text: `/goal ${condition}`,
8229 attachments: [],
8230 }),
8231 );
8232 for (const frame of [
8233 syntheticFrame("goal-set", `Goal set: ${condition}`),
8234 makeAssistantTextFrame({ uuid: "goal-work-1", text: "Fixing the first test." }),
8235 stopHookFeedback("goal-check-1", condition, "One test still fails."),
8236 makeAssistantTextFrame({ uuid: "goal-work-2", text: "All tests pass now." }),
8237 makeResultFrame({ uuid: "goal-result", result: "All tests pass now." }),
8238 ]) {
8239 yield* Queue.offer(harness.sdkMessages, frame);
8240 }
8241 const terminal = yield* Queue.take(harness.terminalReceipts);
8242 assert.equal(terminal.status, "completed");
8243 assert.deepEqual(goalStatuses(harness.events), [
8244 { objective: condition, status: "active", checks: 0 },
8245 {
8246 objective: condition,
8247 status: "active",
8248 checks: 1,
8249 lastCheck: "One test still fails.",
8250 },
8251 { objective: condition, status: "complete", checks: 1 },
8252 ]);
8253 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
8254 );
8255
8256 it.effect("keeps a goal active when a hook stops the turn before the goal passes", () =>
8257 Effect.gen(function* () {
8258 const harness = yield* makeWakeHarness;
8259 const condition = "the deploy succeeds";
8260 yield* harness.runtime.startTurn(
8261 makeClaudeTestTurnInput({
8262 threadId: harness.threadId,
8263 providerThread: harness.providerThread,
8264 now: yield* DateTime.now,
8265 attemptId: RunAttemptId.make("goal-hook-stop-attempt"),
8266 text: `/goal ${condition}`,
8267 attachments: [],
8268 }),
8269 );
8270 for (const frame of [
8271 syntheticFrame("goal-hook-set", `Goal set: ${condition}`),
8272 makeAssistantTextFrame({ uuid: "goal-hook-work", text: "Deploying." }),
8273 makeResultFrame({
8274 uuid: "goal-hook-result",
8275 result: "Deploying.",
8276 terminalReason: "hook_stopped",
8277 }),
8278 ]) {
8279 yield* Queue.offer(harness.sdkMessages, frame);
8280 }
8281 yield* Queue.take(harness.terminalReceipts);
8282 assert.deepEqual(goalStatuses(harness.events).at(-1), {
8283 objective: condition,
8284 status: "active",
8285 checks: 0,
8286 });
8287 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
8288 );
8289
8290 it.effect("keeps a goal active after a command turn with no model output", () =>
8291 Effect.gen(function* () {
8292 const harness = yield* makeWakeHarness;
8293 const condition = "the build is green";
8294 yield* harness.runtime.startTurn(
8295 makeClaudeTestTurnInput({
8296 threadId: harness.threadId,
8297 providerThread: {
8298 ...harness.providerThread,
8299 goal: { objective: condition, status: "active", checks: 2 },
8300 },
8301 now: yield* DateTime.now,
8302 attemptId: RunAttemptId.make("goal-show-attempt"),
8303 text: "/goal",
8304 attachments: [],
8305 }),
8306 );
8307 yield* Queue.offer(
8308 harness.sdkMessages,
8309 syntheticFrame("goal-show", `Goal active: ${condition} (2 turns)`),
8310 );
8311 yield* Queue.offer(
8312 harness.sdkMessages,
8313 makeResultFrame({ uuid: "goal-show-result", result: "", numTurns: 0 }),
8314 );
8315 yield* Queue.take(harness.terminalReceipts);
8316 assert.deepEqual(goalStatuses(harness.events).at(-1), {
8317 objective: condition,
8318 status: "active",
8319 checks: 2,
8320 });
8321 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
8322 );
8323 });
8175});8324});
81768325
8177describe("ClaudeAdapterV2 query message stream", () => {8326describe("ClaudeAdapterV2 query message stream", () => {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+127
...@@ -48,6 +48,7 @@ import {...@@ -48,6 +48,7 @@ import {
48 type OrchestrationV2ExecutionNode,48 type OrchestrationV2ExecutionNode,
49 type OrchestrationV2ProviderCapabilities,49 type OrchestrationV2ProviderCapabilities,
50 type OrchestrationV2ProviderFailure,50 type OrchestrationV2ProviderFailure,
51 OrchestrationV2ProviderGoal,
51 type OrchestrationV2PlanArtifact,52 type OrchestrationV2PlanArtifact,
52 type OrchestrationV2PlanStep,53 type OrchestrationV2PlanStep,
53 type OrchestrationV2PendingBackgroundTask,54 type OrchestrationV2PendingBackgroundTask,
...@@ -1015,6 +1016,64 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"...@@ -1015,6 +1016,64 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"
1015 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");1016 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
1016}1017}
10171018
1019// In SDK mode Claude reports `/goal` only through the transcript (its
1020// `active_goal` event is remote-only): synthetic command output names the
1021// goal, and each unmet evaluator check returns as Stop hook feedback.
1022const CLAUDE_GOAL_SET_PREFIX = "Goal set: ";
1023const CLAUDE_GOAL_ACTIVE = /^Goal active: ([\s\S]+?) \((?:not yet evaluated|(\d+) turns?)\)/u;
1024
1025/**
1026 * The goal after one root SDK frame, or undefined when the frame says nothing
1027 * about it. Completion has no frame of its own; see finalizeActiveTurn.
1028 */
1029function nextClaudeGoal(
1030 current: OrchestrationV2ProviderGoal | null,
1031 message: SDKMessage,
1032): OrchestrationV2ProviderGoal | null | undefined {
1033 if (message.type === "assistant") {
1034 if (message.parent_tool_use_id !== null || message.message.model !== "<synthetic>") {
1035 return undefined;
1036 }
1037 const text = textFromClaudeContent(message.message.content).trim();
1038 if (text.startsWith(CLAUDE_GOAL_SET_PREFIX)) {
1039 const objective = text.slice(CLAUDE_GOAL_SET_PREFIX.length).trim();
1040 return objective.length === 0 ? undefined : { objective, status: "active", checks: 0 };
1041 }
1042 if (text.startsWith("Goal cleared: ") || text.startsWith("No goal set")) return null;
1043 const active = CLAUDE_GOAL_ACTIVE.exec(text);
1044 if (active?.[1] === undefined) return undefined;
1045 return {
1046 ...(current?.objective === active[1] ? current : {}),
1047 objective: active[1],
1048 status: "active",
1049 checks: Number(active[2] ?? 0),
1050 };
1051 }
1052 if (
1053 message.type !== "user" ||
1054 message.parent_tool_use_id !== null ||
1055 message.isSynthetic !== true ||
1056 current === null
1057 ) {
1058 return undefined;
1059 }
1060 const prefix = `Stop hook feedback:\n[${current.objective}]: `;
1061 const content = message.message.content;
1062 const text =
1063 typeof content === "string"
1064 ? content
1065 : content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
1066 if (!text.startsWith(prefix)) return undefined;
1067 return {
1068 ...current,
1069 status: "active",
1070 checks: (current.checks ?? 0) + 1,
1071 lastCheck: text.slice(prefix.length).trim(),
1072 };
1073}
1074
1075const providerGoalsEqual = Schema.toEquivalence(Schema.NullOr(OrchestrationV2ProviderGoal));
1076
1018/** Compares a subagent result with its routed text regardless of block joins. */1077/** Compares a subagent result with its routed text regardless of block joins. */
1019function normalizeClaudeResultText(text: string): string {1078function normalizeClaudeResultText(text: string): string {
1020 return text.replace(/\s+/g, " ").trim();1079 return text.replace(/\s+/g, " ").trim();
...@@ -3049,6 +3108,10 @@ export function makeClaudeAdapterV2(...@@ -3049,6 +3108,10 @@ export function makeClaudeAdapterV2(
3049 const lastProviderThreadByNativeThread = yield* Ref.make(3108 const lastProviderThreadByNativeThread = yield* Ref.make(
3050 new Map<string, OrchestrationV2ProviderThread>(),3109 new Map<string, OrchestrationV2ProviderThread>(),
3051 );3110 );
3111 // Native `/goal` per session, seeded from the persisted provider thread.
3112 const goalsByNativeThread = new Map<string, OrchestrationV2ProviderGoal | null>();
3113 // Turns whose model output met an active goal's Stop hook check at its end.
3114 const goalCheckedTurns = new Set<string>();
3052 // Subagent registry that survives turn settle: a background subagent3115 // Subagent registry that survives turn settle: a background subagent
3053 // (Agent with run_in_background) can complete after the root turn3116 // (Agent with run_in_background) can complete after the root turn
3054 // ended, and its task_notification must both count as wake evidence3117 // ended, and its task_notification must both count as wake evidence
...@@ -3452,6 +3515,9 @@ export function makeClaudeAdapterV2(...@@ -3452,6 +3515,9 @@ export function makeClaudeAdapterV2(
3452 providerSessionId: session.id,3515 providerSessionId: session.id,
3453 ...(input.status === undefined ? {} : { status: input.status }),3516 ...(input.status === undefined ? {} : { status: input.status }),
3454 pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster),3517 pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster),
3518 ...(goalsByNativeThread.has(input.nativeThreadId)
3519 ? { goal: goalsByNativeThread.get(input.nativeThreadId) ?? null }
3520 : {}),
3455 updatedAt: now,3521 updatedAt: now,
3456 };3522 };
3457 yield* rememberProviderThread(providerThread);3523 yield* rememberProviderThread(providerThread);
...@@ -3462,6 +3528,37 @@ export function makeClaudeAdapterV2(...@@ -3462,6 +3528,37 @@ export function makeClaudeAdapterV2(
3462 });3528 });
3463 });3529 });
34643530
3531 /** Applies one root frame's goal signal and writes any change onto the provider thread. */
3532 const trackClaudeGoal = Effect.fnUntraced(function* (input: {
3533 readonly nativeThreadId: string;
3534 readonly context: ActiveClaudeTurnContext;
3535 readonly message: SDKMessage;
3536 }) {
3537 const current = goalsByNativeThread.get(input.nativeThreadId) ?? null;
3538 if (
3539 current?.status === "active" &&
3540 input.message.type === "assistant" &&
3541 input.message.parent_tool_use_id === null &&
3542 input.message.message.model !== "<synthetic>"
3543 ) {
3544 goalCheckedTurns.add(input.context.providerTurnId);
3545 }
3546 const next = nextClaudeGoal(current, input.message);
3547 if (next === undefined || providerGoalsEqual(current, next)) return;
3548 goalsByNativeThread.set(input.nativeThreadId, next);
3549 const providerThread = (yield* Ref.get(lastProviderThreadByNativeThread)).get(
3550 input.nativeThreadId,
3551 );
3552 if (providerThread === undefined) return;
3553 const updated = { ...providerThread, goal: next, updatedAt: yield* DateTime.now };
3554 yield* rememberProviderThread(updated);
3555 yield* emitProviderEvent({
3556 type: "provider_thread.updated",
3557 driver: CLAUDE_PROVIDER,
3558 providerThread: updated,
3559 });
3560 });
3561
3465 const rememberOpaqueTasks = (tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>) =>3562 const rememberOpaqueTasks = (tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>) =>
3466 tasks.length === 03563 tasks.length === 0
3467 ? Effect.void3564 ? Effect.void
...@@ -4975,10 +5072,36 @@ export function makeClaudeAdapterV2(...@@ -4975,10 +5072,36 @@ export function makeClaudeAdapterV2(
4975 const clearConversationHead =5072 const clearConversationHead =
4976 input.status === "completed" &&5073 input.status === "completed" &&
4977 input.context.input.providerThread.nativeConversationHeadRef !== null;5074 input.context.input.providerThread.nativeConversationHeadRef !== null;
5075 // While a goal is set, Claude ends a turn on its own only after the
5076 // goal's Stop hook passes. It defers that check while background
5077 // work runs, and a hook that stops the turn reports another reason.
5078 // SDK mode does not report an evaluator timeout or an impossible
5079 // verdict, so those still read as complete.
5080 const goal =
5081 nativeThreadId === null ? undefined : goalsByNativeThread.get(nativeThreadId);
5082 const goalChecked = goalCheckedTurns.delete(input.context.providerTurnId);
5083 const terminalReason = input.result?.terminal_reason;
5084 if (
5085 nativeThreadId !== null &&
5086 goal?.status === "active" &&
5087 goalChecked &&
5088 input.status === "completed" &&
5089 (terminalReason === undefined || terminalReason === "completed") &&
5090 roster.size === 0
5091 ) {
5092 goalsByNativeThread.set(nativeThreadId, {
5093 objective: goal.objective,
5094 status: "complete",
5095 ...(goal.checks === undefined ? {} : { checks: goal.checks }),
5096 });
5097 }
4978 const providerThread: OrchestrationV2ProviderThread = {5098 const providerThread: OrchestrationV2ProviderThread = {
4979 ...input.context.input.providerThread,5099 ...input.context.input.providerThread,
4980 providerSessionId: session.id,5100 providerSessionId: session.id,
4981 ...(clearConversationHead ? { nativeConversationHeadRef: null } : {}),5101 ...(clearConversationHead ? { nativeConversationHeadRef: null } : {}),
5102 ...(nativeThreadId !== null && goalsByNativeThread.has(nativeThreadId)
5103 ? { goal: goalsByNativeThread.get(nativeThreadId) ?? null }
5104 : {}),
4982 firstRunOrdinal:5105 firstRunOrdinal:
4983 input.context.input.providerThread.firstRunOrdinal ??5106 input.context.input.providerThread.firstRunOrdinal ??
4984 input.context.input.runOrdinal,5107 input.context.input.runOrdinal,
...@@ -5552,6 +5675,7 @@ export function makeClaudeAdapterV2(...@@ -5552,6 +5675,7 @@ export function makeClaudeAdapterV2(
5552 }5675 }
5553 return;5676 return;
5554 }5677 }
5678 yield* trackClaudeGoal({ nativeThreadId: liveQuery.nativeThreadId, context, message });
55555679
5556 // Subagent narration belongs to its child thread, never the parent log.5680 // Subagent narration belongs to its child thread, never the parent log.
5557 if (message.type === "stream_event" && !message.parent_tool_use_id) {5681 if (message.type === "stream_event" && !message.parent_tool_use_id) {
...@@ -7137,6 +7261,9 @@ export function makeClaudeAdapterV2(...@@ -7137,6 +7261,9 @@ export function makeClaudeAdapterV2(
7137 return updated;7261 return updated;
7138 });7262 });
7139 yield* rememberProviderThread(turnInput.providerThread);7263 yield* rememberProviderThread(turnInput.providerThread);
7264 if (!goalsByNativeThread.has(nativeThreadId)) {
7265 goalsByNativeThread.set(nativeThreadId, turnInput.providerThread.goal ?? null);
7266 }
7140 const context: ActiveClaudeTurnContext = {7267 const context: ActiveClaudeTurnContext = {
7141 input: turnInput,7268 input: turnInput,
7142 nativeTurnId,7269 nativeTurnId,
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+843-1
...@@ -1195,6 +1195,11 @@ describe("CodexAdapterV2 rollback mapping", () => {...@@ -1195,6 +1195,11 @@ describe("CodexAdapterV2 rollback mapping", () => {
1195 const secondTurn = providerTurn("provider-turn-second", 2, "completed");1195 const secondTurn = providerTurn("provider-turn-second", 2, "completed");
1196 const runningTurn = providerTurn("provider-turn-running", 3, "running");1196 const runningTurn = providerTurn("provider-turn-running", 3, "running");
1197 const interruptedTurn = providerTurn("provider-turn-interrupted", 4, "interrupted");1197 const interruptedTurn = providerTurn("provider-turn-interrupted", 4, "interrupted");
1198 // `/goal` control turns never reached Codex, so revert must not count them.
1199 const goalCommandTurn = {
1200 ...providerTurn("provider-turn-goal-command", 5, "completed"),
1201 nativeTurnRef: null,
1202 };
11981203
1199 const numTurns = yield* CodexAdapterV2.resolveCodexRollbackTurnCount({1204 const numTurns = yield* CodexAdapterV2.resolveCodexRollbackTurnCount({
1200 providerThread,1205 providerThread,
...@@ -1204,7 +1209,7 @@ describe("CodexAdapterV2 rollback mapping", () => {...@@ -1204,7 +1209,7 @@ describe("CodexAdapterV2 rollback mapping", () => {
1204 appRunOrdinal: 1,1209 appRunOrdinal: 1,
1205 providerTurn: firstTurn,1210 providerTurn: firstTurn,
1206 },1211 },
1207 providerThreadTurns: [interruptedTurn, runningTurn, secondTurn, firstTurn],1212 providerThreadTurns: [goalCommandTurn, interruptedTurn, runningTurn, secondTurn, firstTurn],
1208 });1213 });
12091214
1210 assert.equal(numTurns, 2);1215 assert.equal(numTurns, 2);
...@@ -7328,4 +7333,841 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -7328,4 +7333,841 @@ describe("CodexAdapterV2 post-settle continuation", () => {
7328 assert.include(errorCauseChainText(error), "fork exploded");7333 assert.include(errorCauseChainText(error), "fork exploded");
7329 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),7334 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7330 );7335 );
7336
7337 describe("native goals", () => {
7338 const nativeThreadId = "goal-thread";
7339 const objective = "Ship the feature";
7340 const codexGoal = (status: string, tokensUsed: number) => ({
7341 threadId: nativeThreadId,
7342 objective,
7343 status,
7344 tokenBudget: null,
7345 tokensUsed,
7346 timeUsedSeconds: 3,
7347 createdAt: 1782622440,
7348 updatedAt: 1782622450,
7349 });
7350 const notification = (
7351 label: string,
7352 method: string,
7353 params: unknown,
7354 ): CodexReplay.CodexAppServerReplayEntry => ({
7355 type: "emit_inbound",
7356 label,
7357 frame: { method, params },
7358 });
7359 const request = (id: number, method: string, params: unknown, result: unknown) =>
7360 [
7361 { type: "expect_outbound", label: method, frame: { id, method, params } },
7362 { type: "emit_inbound", label: method, frame: { id, result } },
7363 ] satisfies Array<CodexReplay.CodexAppServerReplayEntry>;
7364 const withReplayRequestId = (
7365 entries: ReadonlyArray<CodexReplay.CodexAppServerReplayEntry>,
7366 id: number,
7367 ) =>
7368 entries.map((entry) =>
7369 "frame" in entry && Predicate.isObject(entry.frame) && "id" in entry.frame
7370 ? { ...entry, frame: { ...entry.frame, id } }
7371 : entry,
7372 );
7373 const sessionStart = codexReplayPreamble({
7374 nativeThreadId,
7375 nativeTurnId: "unused",
7376 prompt: "unused",
7377 }).slice(0, 5);
7378 const goalTurnInput = (
7379 harness: {
7380 readonly threadId: ThreadId;
7381 readonly providerThread: OrchestrationV2ProviderThread;
7382 },
7383 text: string,
7384 ) =>
7385 DateTime.now.pipe(
7386 Effect.map((now) =>
7387 makeCodexTestTurnInput({
7388 threadId: harness.threadId,
7389 providerThread: harness.providerThread,
7390 now,
7391 attemptId: RunAttemptId.make("goal-attempt"),
7392 text,
7393 }),
7394 ),
7395 );
7396 const providerGoals = (events: ReadonlyArray<ProviderAdapterV2Event>) =>
7397 events.flatMap((event) =>
7398 event.type === "provider_thread.updated" ? [event.providerThread.goal?.status ?? null] : [],
7399 );
7400
7401 it("parses /goal like the Codex TUI", () => {
7402 assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal"), { type: "show" });
7403 assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand(" /goal Pause "), { type: "pause" });
7404 assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal resume"), { type: "resume" });
7405 assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal clear"), { type: "clear" });
7406 assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal fix the\nflaky tests"), {
7407 type: "set",
7408 objective: "fix the\nflaky tests",
7409 });
7410 assert.isNull(CodexAdapterV2.parseCodexGoalCommand("/goals"));
7411 assert.isNull(CodexAdapterV2.parseCodexGoalCommand("set a /goal"));
7412 });
7413
7414 it.effect("keeps a /goal run open across the turns Codex continues on its own", () =>
7415 Effect.gen(function* () {
7416 const transcript = makeCodexReplayTranscript({
7417 scenario: "goal-continuation",
7418 entries: [
7419 ...sessionStart,
7420 ...request(3, "thread/goal/get", { threadId: nativeThreadId }, { goal: null }),
7421 ...request(
7422 4,
7423 "thread/goal/set",
7424 { threadId: nativeThreadId, objective, status: "paused" },
7425 { goal: codexGoal("paused", 0) },
7426 ),
7427 // The first goal turn carries this run's turn configuration.
7428 ...withReplayRequestId(
7429 codexReplayPreamble({
7430 nativeThreadId,
7431 nativeTurnId: "goal-turn-1",
7432 prompt: objective,
7433 }).slice(5),
7434 5,
7435 ),
7436 ...request(
7437 6,
7438 "thread/goal/set",
7439 { threadId: nativeThreadId, status: "active" },
7440 { goal: codexGoal("active", 0) },
7441 ),
7442 notification("goal active", "thread/goal/updated", {
7443 threadId: nativeThreadId,
7444 turnId: "goal-turn-1",
7445 goal: codexGoal("active", 0),
7446 }),
7447 notification("first turn done", "turn/completed", {
7448 threadId: nativeThreadId,
7449 turn: makeCodexReplayTurn({ id: "goal-turn-1", status: "completed" }),
7450 }),
7451 notification("continuation", "turn/started", {
7452 threadId: nativeThreadId,
7453 turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "inProgress" }),
7454 }),
7455 notification("goal complete", "thread/goal/updated", {
7456 threadId: nativeThreadId,
7457 turnId: "goal-turn-2",
7458 goal: codexGoal("complete", 1200),
7459 }),
7460 notification("continuation done", "turn/completed", {
7461 threadId: nativeThreadId,
7462 turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "completed" }),
7463 }),
7464 ],
7465 });
7466 const harness = yield* makeCodexReplayHarness(transcript);
7467 yield* harness.runtime.startTurn(yield* goalTurnInput(harness, `/goal ${objective}`));
7468 yield* harness.firstTerminal;
7469
7470 const idAllocator = yield* IdAllocator.IdAllocatorV2;
7471 const providerTurnId = (nativeTurnId: string) =>
7472 idAllocator.derive.providerTurn({
7473 driver: CodexAdapterV2.CODEX_DRIVER_KIND,
7474 nativeTurnId,
7475 });
7476 assert.deepEqual(
7477 harness.terminalEvents().map((event) => [event.providerTurnId, event.status]),
7478 [[providerTurnId("goal-turn-2"), "completed"]],
7479 );
7480 assert.deepEqual(
7481 harness.events.flatMap((event) =>
7482 event.type === "provider_turn.updated" && event.providerTurn.status === "running"
7483 ? [
7484 [
7485 event.providerTurn.id,
7486 event.providerTurn.runAttemptId,
7487 event.providerTurn.ordinal,
7488 ],
7489 ]
7490 : [],
7491 ),
7492 [
7493 [providerTurnId("goal-turn-1"), RunAttemptId.make("goal-attempt"), 1],
7494 [providerTurnId("goal-turn-2"), RunAttemptId.make("goal-attempt"), 2],
7495 ],
7496 );
7497 assert.equal(providerGoals(harness.events).at(-1), "complete");
7498 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7499 );
7500
7501 it.effect("settles /goal pause with a reply instead of a native turn", () =>
7502 Effect.gen(function* () {
7503 const transcript = makeCodexReplayTranscript({
7504 scenario: "goal-pause",
7505 entries: [
7506 ...sessionStart,
7507 ...request(
7508 3,
7509 "thread/goal/get",
7510 { threadId: nativeThreadId },
7511 { goal: codexGoal("active", 10) },
7512 ),
7513 ...request(
7514 4,
7515 "thread/goal/set",
7516 { threadId: nativeThreadId, status: "paused" },
7517 { goal: codexGoal("paused", 10) },
7518 ),
7519 ],
7520 });
7521 const harness = yield* makeCodexReplayHarness(transcript);
7522 yield* harness.runtime.startTurn(yield* goalTurnInput(harness, "/goal pause"));
7523 yield* harness.firstTerminal;
7524
7525 assert.deepEqual(
7526 harness.terminalEvents().map((event) => event.status),
7527 ["completed"],
7528 );
7529 const turns = harness.events.flatMap((event) =>
7530 event.type === "provider_turn.updated" ? [event.providerTurn] : [],
7531 );
7532 assert.isTrue(turns.every((turn) => turn.nativeTurnRef === null));
7533 assert.include(
7534 harness.events.flatMap((event) =>
7535 event.type === "message.updated" && event.message.role === "assistant"
7536 ? [event.message.text]
7537 : [],
7538 ),
7539 "Goal paused. Send /goal resume to continue.",
7540 );
7541 assert.equal(providerGoals(harness.events).at(-1), "paused");
7542 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7543 );
7544
7545 it.effect("settles a run held for the next goal turn when Stop arrives in between", () =>
7546 Effect.gen(function* () {
7547 const prompt = "Keep going";
7548 const transcript = makeCodexReplayTranscript({
7549 scenario: "goal-stop-between-turns",
7550 entries: [
7551 ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }),
7552 notification("goal active", "thread/goal/updated", {
7553 threadId: nativeThreadId,
7554 turnId: "goal-turn",
7555 goal: codexGoal("active", 10),
7556 }),
7557 notification("turn done", "turn/completed", {
7558 threadId: nativeThreadId,
7559 turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }),
7560 }),
7561 // Notifications run in order, so seeing this one proves the turn settled.
7562 notification("goal accounted", "thread/goal/updated", {
7563 threadId: nativeThreadId,
7564 turnId: null,
7565 goal: codexGoal("active", 11),
7566 }),
7567 ...request(
7568 4,
7569 "thread/goal/set",
7570 { threadId: nativeThreadId, status: "paused" },
7571 { goal: codexGoal("paused", 10) },
7572 ),
7573 notification("goal paused", "thread/goal/updated", {
7574 threadId: nativeThreadId,
7575 turnId: null,
7576 goal: codexGoal("paused", 10),
7577 }),
7578 ],
7579 });
7580 const requests: Array<string> = [];
7581 const harness = yield* makeCodexReplayHarness(
7582 transcript,
7583 () => Effect.void,
7584 (method) =>
7585 Effect.sync(() => {
7586 requests.push(method);
7587 }),
7588 );
7589 yield* harness.runtime.startTurn(
7590 makeCodexTestTurnInput({
7591 threadId: harness.threadId,
7592 providerThread: harness.providerThread,
7593 now: yield* DateTime.now,
7594 attemptId: RunAttemptId.make("goal-hold-attempt"),
7595 text: prompt,
7596 }),
7597 );
7598 const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
7599 driver: CodexAdapterV2.CODEX_DRIVER_KIND,
7600 nativeTurnId: "goal-turn",
7601 });
7602 const turnStatuses = () =>
7603 harness.events.flatMap((event) =>
7604 event.type === "provider_turn.updated" && event.providerTurn.id === providerTurnId
7605 ? [event.providerTurn.status]
7606 : [],
7607 );
7608 yield* awaitUntil(
7609 () =>
7610 harness.events.some(
7611 (event) =>
7612 event.type === "provider_thread.updated" &&
7613 event.providerThread.goal?.tokensUsed === 11,
7614 ),
7615 "the completed turn to be held",
7616 );
7617 // The held turn keeps reading as running, so Stop still reaches the adapter.
7618 assert.deepEqual(turnStatuses(), ["running"]);
7619 assert.lengthOf(harness.terminalEvents(), 0);
7620
7621 yield* harness.runtime.interruptTurn({
7622 providerThread: harness.providerThread,
7623 providerTurnId,
7624 });
7625 yield* harness.firstTerminal;
7626
7627 assert.notInclude(requests, "turn/interrupt");
7628 assert.deepEqual(turnStatuses(), ["running", "completed"]);
7629 assert.deepEqual(
7630 harness.terminalEvents().map((event) => event.status),
7631 ["interrupted"],
7632 );
7633 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7634 );
7635
7636 it.effect("moves a Stop that names an earlier goal turn to the turn Codex continued with", () =>
7637 Effect.gen(function* () {
7638 const prompt = "Keep going";
7639 const transcript = makeCodexReplayTranscript({
7640 scenario: "goal-stop-after-continuation",
7641 entries: [
7642 ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }),
7643 notification("goal active", "thread/goal/updated", {
7644 threadId: nativeThreadId,
7645 turnId: "goal-turn-a",
7646 goal: codexGoal("active", 10),
7647 }),
7648 notification("first turn done", "turn/completed", {
7649 threadId: nativeThreadId,
7650 turn: makeCodexReplayTurn({ id: "goal-turn-a", status: "completed" }),
7651 }),
7652 notification("continuation", "turn/started", {
7653 threadId: nativeThreadId,
7654 turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "inProgress" }),
7655 }),
7656 notification("goal accounted", "thread/goal/updated", {
7657 threadId: nativeThreadId,
7658 turnId: "goal-turn-b",
7659 goal: codexGoal("active", 11),
7660 }),
7661 ...request(
7662 4,
7663 "thread/goal/set",
7664 { threadId: nativeThreadId, status: "paused" },
7665 { goal: codexGoal("paused", 11) },
7666 ),
7667 ...request(
7668 5,
7669 "turn/interrupt",
7670 { threadId: nativeThreadId, turnId: "goal-turn-b" },
7671 {},
7672 ),
7673 notification("continuation interrupted", "turn/completed", {
7674 threadId: nativeThreadId,
7675 turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "interrupted" }),
7676 }),
7677 ],
7678 });
7679 const harness = yield* makeCodexReplayHarness(transcript);
7680 yield* harness.runtime.startTurn(
7681 makeCodexTestTurnInput({
7682 threadId: harness.threadId,
7683 providerThread: harness.providerThread,
7684 now: yield* DateTime.now,
7685 attemptId: RunAttemptId.make("goal-successor-attempt"),
7686 text: prompt,
7687 }),
7688 );
7689 yield* awaitUntil(
7690 () =>
7691 harness.events.some(
7692 (event) =>
7693 event.type === "provider_thread.updated" &&
7694 event.providerThread.goal?.tokensUsed === 11,
7695 ),
7696 "the continuation to be adopted",
7697 );
7698 // The projection may still name the first turn when Stop is dispatched.
7699 yield* harness.runtime.interruptTurn({
7700 providerThread: harness.providerThread,
7701 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
7702 driver: CodexAdapterV2.CODEX_DRIVER_KIND,
7703 nativeTurnId: "goal-turn-a",
7704 }),
7705 requestRuntimeRestart: true,
7706 });
7707 yield* harness.firstTerminal;
7708 assert.deepEqual(
7709 harness.terminalEvents().map((event) => event.status),
7710 ["interrupted"],
7711 );
7712 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7713 );
7714
7715 it.effect("stops a held goal run's background command when Stop arrives between turns", () =>
7716 Effect.gen(function* () {
7717 const prompt = "Start the server";
7718 const command = "node server.js";
7719 const commandItem = (status: "inProgress" | "completed") => ({
7720 type: "commandExecution",
7721 id: "goal-bg-command",
7722 command,
7723 cwd: "/workspace",
7724 processId: "4242",
7725 source: "unifiedExecStartup",
7726 status,
7727 commandActions: [{ type: "unknown", command }],
7728 aggregatedOutput: null,
7729 exitCode: null,
7730 durationMs: null,
7731 });
7732 const transcript = makeCodexReplayTranscript({
7733 scenario: "goal-stop-held-background",
7734 entries: [
7735 ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }),
7736 notification("command started", "item/started", {
7737 item: commandItem("inProgress"),
7738 threadId: nativeThreadId,
7739 turnId: "goal-turn",
7740 startedAtMs: 1782622440500,
7741 }),
7742 notification("goal active", "thread/goal/updated", {
7743 threadId: nativeThreadId,
7744 turnId: "goal-turn",
7745 goal: codexGoal("active", 10),
7746 }),
7747 notification("turn done", "turn/completed", {
7748 threadId: nativeThreadId,
7749 turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }),
7750 }),
7751 notification("goal accounted", "thread/goal/updated", {
7752 threadId: nativeThreadId,
7753 turnId: null,
7754 goal: codexGoal("active", 11),
7755 }),
7756 ...request(
7757 4,
7758 "thread/goal/set",
7759 { threadId: nativeThreadId, status: "paused" },
7760 { goal: codexGoal("paused", 11) },
7761 ),
7762 ...request(
7763 5,
7764 "thread/backgroundTerminals/terminate",
7765 { threadId: nativeThreadId, processId: "4242" },
7766 { terminated: true },
7767 ),
7768 ],
7769 });
7770 const harness = yield* makeCodexReplayHarness(transcript);
7771 yield* harness.runtime.startTurn(
7772 makeCodexTestTurnInput({
7773 threadId: harness.threadId,
7774 providerThread: harness.providerThread,
7775 now: yield* DateTime.now,
7776 attemptId: RunAttemptId.make("goal-held-background-attempt"),
7777 text: prompt,
7778 }),
7779 );
7780 yield* awaitUntil(
7781 () =>
7782 harness.events.some(
7783 (event) =>
7784 event.type === "provider_thread.updated" &&
7785 event.providerThread.goal?.tokensUsed === 11,
7786 ),
7787 "the completed turn to be held",
7788 );
7789 yield* harness.runtime.interruptTurn({
7790 providerThread: harness.providerThread,
7791 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
7792 driver: CodexAdapterV2.CODEX_DRIVER_KIND,
7793 nativeTurnId: "goal-turn",
7794 }),
7795 requestRuntimeRestart: true,
7796 });
7797 yield* harness.firstTerminal;
7798 // The settled-turn path terminates the process (the replay expects that
7799 // request) before it marks the command interrupted.
7800 assert.deepEqual(
7801 harness.terminalEvents().map((event) => event.status),
7802 ["interrupted"],
7803 );
7804 yield* awaitUntil(
7805 () =>
7806 harness.events.some(
7807 (event) =>
7808 event.type === "turn_item.updated" &&
7809 event.turnItem.type === "command_execution" &&
7810 event.turnItem.status === "interrupted",
7811 ),
7812 "the background command to be interrupted",
7813 );
7814 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7815 );
7816
7817 it.effect("moves Stop to a continuation Codex starts while the goal pause is in flight", () =>
7818 Effect.gen(function* () {
7819 const prompt = "Keep going";
7820 const transcript = makeCodexReplayTranscript({
7821 scenario: "goal-stop-races-continuation",
7822 entries: [
7823 ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }),
7824 notification("goal active", "thread/goal/updated", {
7825 threadId: nativeThreadId,
7826 turnId: "goal-turn-a",
7827 goal: codexGoal("active", 10),
7828 }),
7829 {
7830 type: "expect_outbound",
7831 label: "thread/goal/set",
7832 frame: {
7833 id: 4,
7834 method: "thread/goal/set",
7835 params: { threadId: nativeThreadId, status: "paused" },
7836 },
7837 },
7838 notification("first turn done", "turn/completed", {
7839 threadId: nativeThreadId,
7840 turn: makeCodexReplayTurn({ id: "goal-turn-a", status: "completed" }),
7841 }),
7842 notification("continuation", "turn/started", {
7843 threadId: nativeThreadId,
7844 turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "inProgress" }),
7845 }),
7846 {
7847 type: "emit_inbound",
7848 label: "thread/goal/set",
7849 frame: { id: 4, result: { goal: codexGoal("paused", 11) } },
7850 },
7851 ...request(
7852 5,
7853 "turn/interrupt",
7854 { threadId: nativeThreadId, turnId: "goal-turn-b" },
7855 {},
7856 ),
7857 notification("continuation interrupted", "turn/completed", {
7858 threadId: nativeThreadId,
7859 turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "interrupted" }),
7860 }),
7861 ],
7862 });
7863 const harness = yield* makeCodexReplayHarness(transcript);
7864 yield* harness.runtime.startTurn(
7865 makeCodexTestTurnInput({
7866 threadId: harness.threadId,
7867 providerThread: harness.providerThread,
7868 now: yield* DateTime.now,
7869 attemptId: RunAttemptId.make("goal-stop-race-attempt"),
7870 text: prompt,
7871 }),
7872 );
7873 yield* awaitUntil(
7874 () =>
7875 harness.events.some(
7876 (event) =>
7877 event.type === "provider_thread.updated" &&
7878 event.providerThread.goal?.status === "active",
7879 ),
7880 "the goal to be active",
7881 );
7882 // Stop names the first turn while it still runs.
7883 yield* harness.runtime.interruptTurn({
7884 providerThread: harness.providerThread,
7885 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
7886 driver: CodexAdapterV2.CODEX_DRIVER_KIND,
7887 nativeTurnId: "goal-turn-a",
7888 }),
7889 requestRuntimeRestart: true,
7890 });
7891 yield* harness.firstTerminal;
7892 assert.deepEqual(
7893 harness.terminalEvents().map((event) => event.status),
7894 ["interrupted"],
7895 );
7896 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7897 );
7898
7899 it.effect("stops a subagent an earlier goal turn left running when Stop settles the run", () =>
7900 Effect.gen(function* () {
7901 const prompt = "Delegate the audit";
7902 const childThreadId = "goal-child-thread";
7903 const childTurnId = "goal-child-turn";
7904 const transcript = makeCodexReplayTranscript({
7905 scenario: "goal-stop-held-subagent",
7906 entries: [
7907 ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }),
7908 notification("subagent started", "item/completed", {
7909 item: {
7910 type: "subAgentActivity",
7911 id: "goal-subagent-call",
7912 kind: "started",
7913 agentThreadId: childThreadId,
7914 agentPath: "/root/audit",
7915 },
7916 threadId: nativeThreadId,
7917 turnId: "goal-turn",
7918 completedAtMs: 1782622441000,
7919 }),
7920 notification("child turn", "turn/started", {
7921 threadId: childThreadId,
7922 turn: makeCodexReplayTurn({ id: childTurnId, status: "inProgress" }),
7923 }),
7924 notification("goal active", "thread/goal/updated", {
7925 threadId: nativeThreadId,
7926 turnId: "goal-turn",
7927 goal: codexGoal("active", 10),
7928 }),
7929 notification("turn done", "turn/completed", {
7930 threadId: nativeThreadId,
7931 turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }),
7932 }),
7933 notification("goal accounted", "thread/goal/updated", {
7934 threadId: nativeThreadId,
7935 turnId: null,
7936 goal: codexGoal("active", 11),
7937 }),
7938 ...request(
7939 4,
7940 "thread/goal/set",
7941 { threadId: nativeThreadId, status: "paused" },
7942 { goal: codexGoal("paused", 11) },
7943 ),
7944 ...request(5, "turn/interrupt", { threadId: childThreadId, turnId: childTurnId }, {}),
7945 notification("child interrupted", "turn/completed", {
7946 threadId: childThreadId,
7947 turn: makeCodexReplayTurn({ id: childTurnId, status: "interrupted" }),
7948 }),
7949 ],
7950 });
7951 const harness = yield* makeCodexReplayHarness(transcript);
7952 yield* harness.runtime.startTurn(
7953 makeCodexTestTurnInput({
7954 threadId: harness.threadId,
7955 providerThread: harness.providerThread,
7956 now: yield* DateTime.now,
7957 attemptId: RunAttemptId.make("goal-held-subagent-attempt"),
7958 text: prompt,
7959 }),
7960 );
7961 yield* awaitUntil(
7962 () =>
7963 harness.events.some(
7964 (event) =>
7965 event.type === "provider_thread.updated" &&
7966 event.providerThread.goal?.tokensUsed === 11,
7967 ),
7968 "the completed turn to be held",
7969 );
7970 yield* harness.runtime.interruptTurn({
7971 providerThread: harness.providerThread,
7972 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
7973 driver: CodexAdapterV2.CODEX_DRIVER_KIND,
7974 nativeTurnId: "goal-turn",
7975 }),
7976 requestRuntimeRestart: true,
7977 });
7978 yield* harness.firstTerminal;
7979 assert.deepEqual(
7980 harness.terminalEvents().map((event) => event.status),
7981 ["interrupted"],
7982 );
7983 yield* awaitUntil(
7984 () =>
7985 harness.events.some(
7986 (event) =>
7987 event.type === "provider_turn.updated" &&
7988 event.providerTurn.nativeTurnRef?.nativeId === childTurnId &&
7989 event.providerTurn.status === "interrupted",
7990 ),
7991 "the subagent turn to be interrupted",
7992 );
7993 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7994 );
7995
7996 it.effect("stops an earlier goal turn's subagent when the run settles during the pause", () =>
7997 Effect.gen(function* () {
7998 const prompt = "Delegate the audit";
7999 const childThreadId = "goal-race-child-thread";
8000 const childTurnId = "goal-race-child-turn";
8001 const transcript = makeCodexReplayTranscript({
8002 scenario: "goal-stop-run-settles-during-pause",
8003 entries: [
8004 ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }),
8005 notification("subagent started", "item/completed", {
8006 item: {
8007 type: "subAgentActivity",
8008 id: "goal-race-subagent-call",
8009 kind: "started",
8010 agentThreadId: childThreadId,
8011 agentPath: "/root/audit",
8012 },
8013 threadId: nativeThreadId,
8014 turnId: "goal-turn-a",
8015 completedAtMs: 1782622441000,
8016 }),
8017 notification("child turn", "turn/started", {
8018 threadId: childThreadId,
8019 turn: makeCodexReplayTurn({ id: childTurnId, status: "inProgress" }),
8020 }),
8021 notification("goal active", "thread/goal/updated", {
8022 threadId: nativeThreadId,
8023 turnId: "goal-turn-a",
8024 goal: codexGoal("active", 10),
8025 }),
8026 notification("first turn done", "turn/completed", {
8027 threadId: nativeThreadId,
8028 turn: makeCodexReplayTurn({ id: "goal-turn-a", status: "completed" }),
8029 }),
8030 notification("continuation", "turn/started", {
8031 threadId: nativeThreadId,
8032 turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "inProgress" }),
8033 }),
8034 notification("goal accounted", "thread/goal/updated", {
8035 threadId: nativeThreadId,
8036 turnId: "goal-turn-b",
8037 goal: codexGoal("active", 11),
8038 }),
8039 {
8040 type: "expect_outbound",
8041 label: "thread/goal/set",
8042 frame: {
8043 id: 4,
8044 method: "thread/goal/set",
8045 params: { threadId: nativeThreadId, status: "paused" },
8046 },
8047 },
8048 notification("goal paused", "thread/goal/updated", {
8049 threadId: nativeThreadId,
8050 turnId: "goal-turn-b",
8051 goal: codexGoal("paused", 11),
8052 }),
8053 notification("continuation done", "turn/completed", {
8054 threadId: nativeThreadId,
8055 turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "completed" }),
8056 }),
8057 {
8058 type: "emit_inbound",
8059 label: "thread/goal/set",
8060 frame: { id: 4, result: { goal: codexGoal("paused", 11) } },
8061 },
8062 ...request(5, "turn/interrupt", { threadId: childThreadId, turnId: childTurnId }, {}),
8063 notification("child interrupted", "turn/completed", {
8064 threadId: childThreadId,
8065 turn: makeCodexReplayTurn({ id: childTurnId, status: "interrupted" }),
8066 }),
8067 ],
8068 });
8069 const harness = yield* makeCodexReplayHarness(transcript);
8070 yield* harness.runtime.startTurn(
8071 makeCodexTestTurnInput({
8072 threadId: harness.threadId,
8073 providerThread: harness.providerThread,
8074 now: yield* DateTime.now,
8075 attemptId: RunAttemptId.make("goal-settles-during-pause-attempt"),
8076 text: prompt,
8077 }),
8078 );
8079 yield* awaitUntil(
8080 () =>
8081 harness.events.some(
8082 (event) =>
8083 event.type === "provider_thread.updated" &&
8084 event.providerThread.goal?.tokensUsed === 11,
8085 ),
8086 "the continuation to be adopted",
8087 );
8088 yield* harness.runtime.interruptTurn({
8089 providerThread: harness.providerThread,
8090 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
8091 driver: CodexAdapterV2.CODEX_DRIVER_KIND,
8092 nativeTurnId: "goal-turn-b",
8093 }),
8094 requestRuntimeRestart: true,
8095 });
8096 yield* awaitUntil(
8097 () =>
8098 harness.events.some(
8099 (event) =>
8100 event.type === "provider_turn.updated" &&
8101 event.providerTurn.nativeTurnRef?.nativeId === childTurnId &&
8102 event.providerTurn.status === "interrupted",
8103 ),
8104 "the subagent turn to be interrupted",
8105 );
8106 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
8107 );
8108
8109 it.effect("pauses an active goal before Stop interrupts its turn", () =>
8110 Effect.gen(function* () {
8111 const prompt = "Keep going";
8112 const transcript = makeCodexReplayTranscript({
8113 scenario: "goal-stop",
8114 entries: [
8115 ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }),
8116 notification("goal active", "thread/goal/updated", {
8117 threadId: nativeThreadId,
8118 turnId: "goal-turn",
8119 goal: codexGoal("active", 10),
8120 }),
8121 ...request(
8122 4,
8123 "thread/goal/set",
8124 { threadId: nativeThreadId, status: "paused" },
8125 { goal: codexGoal("paused", 10) },
8126 ),
8127 ...request(5, "turn/interrupt", { threadId: nativeThreadId, turnId: "goal-turn" }, {}),
8128 notification("interrupted", "turn/completed", {
8129 threadId: nativeThreadId,
8130 turn: makeCodexReplayTurn({ id: "goal-turn", status: "interrupted" }),
8131 }),
8132 ],
8133 });
8134 const requests: Array<string> = [];
8135 const harness = yield* makeCodexReplayHarness(
8136 transcript,
8137 () => Effect.void,
8138 (method) =>
8139 Effect.sync(() => {
8140 requests.push(method);
8141 }),
8142 );
8143 yield* harness.runtime.startTurn(
8144 makeCodexTestTurnInput({
8145 threadId: harness.threadId,
8146 providerThread: harness.providerThread,
8147 now: yield* DateTime.now,
8148 attemptId: RunAttemptId.make("goal-stop-attempt"),
8149 text: prompt,
8150 }),
8151 );
8152 yield* awaitUntil(
8153 () => providerGoals(harness.events).includes("active"),
8154 "the active goal to reach the provider thread",
8155 );
8156 yield* harness.runtime.interruptTurn({
8157 providerThread: harness.providerThread,
8158 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
8159 driver: CodexAdapterV2.CODEX_DRIVER_KIND,
8160 nativeTurnId: "goal-turn",
8161 }),
8162 });
8163 yield* harness.firstTerminal;
8164
8165 assert.deepEqual(requests.slice(-2), ["thread/goal/set", "turn/interrupt"]);
8166 assert.deepEqual(
8167 harness.terminalEvents().map((event) => event.status),
8168 ["interrupted"],
8169 );
8170 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
8171 );
8172 });
7331});8173});
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+694-104
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 CodexSettings,25 CodexSettings,
26 defaultInstanceIdForDriver,26 defaultInstanceIdForDriver,
27 isOrchestrationV2WorkActive,27 isOrchestrationV2WorkActive,
28 OrchestrationV2ProviderGoal,
28 ProviderDriverKind,29 ProviderDriverKind,
29 type ProviderSetupError,30 type ProviderSetupError,
30} from "@t3tools/contracts";31} from "@t3tools/contracts";
...@@ -148,6 +149,7 @@ import {...@@ -148,6 +149,7 @@ import {
148 type ProviderAdapterV2RollbackThreadInput,149 type ProviderAdapterV2RollbackThreadInput,
149 type ProviderAdapterV2RuntimePolicy,150 type ProviderAdapterV2RuntimePolicy,
150 type ProviderAdapterV2SessionRuntime,151 type ProviderAdapterV2SessionRuntime,
152 type ProviderAdapterV2InterruptInput,
151 type ProviderAdapterV2SteerInput,153 type ProviderAdapterV2SteerInput,
152 type ProviderAdapterV2TurnInput,154 type ProviderAdapterV2TurnInput,
153} from "../ProviderAdapter.ts";155} from "../ProviderAdapter.ts";
...@@ -847,11 +849,78 @@ function providerThreadFromCodexThread(input: {...@@ -847,11 +849,78 @@ function providerThreadFromCodexThread(input: {
847 };849 };
848}850}
849851
852// Counts toward native rollback. `/goal` control turns never reach Codex and
853// carry no native ref, so they must not consume a native turn on revert.
850const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean =>854const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean =>
851 turn.status === "completed" ||855 turn.nativeTurnRef !== null &&
852 turn.status === "interrupted" ||856 (turn.status === "completed" ||
853 turn.status === "failed" ||857 turn.status === "interrupted" ||
854 turn.status === "cancelled";858 turn.status === "failed" ||
859 turn.status === "cancelled");
860
861export type CodexGoalCommand =
862 | { readonly type: "show" }
863 | { readonly type: "clear" }
864 | { readonly type: "pause" }
865 | { readonly type: "resume" }
866 | { readonly type: "set"; readonly objective: string };
867
868/**
869 * Parses a `/goal` message the way the Codex TUI does: `clear`, `pause` and
870 * `resume` control the current goal, a bare `/goal` shows it, and any other
871 * text becomes the new objective. Returns null for every other message.
872 */
873export function parseCodexGoalCommand(text: string): CodexGoalCommand | null {
874 const match = /^\/goal(?:\s+([\s\S]*))?$/u.exec(text.trim());
875 if (match === null) return null;
876 const argument = (match[1] ?? "").trim();
877 switch (argument.toLowerCase()) {
878 case "":
879 case "edit":
880 return { type: "show" };
881 case "clear":
882 return { type: "clear" };
883 case "pause":
884 return { type: "pause" };
885 case "resume":
886 return { type: "resume" };
887 default:
888 return { type: "set", objective: argument };
889 }
890}
891
892type CodexThreadGoal = CodexSchema.V2ThreadGoalUpdatedNotification["goal"];
893
894const CODEX_GOAL_STATUSES = {
895 active: "active",
896 paused: "paused",
897 blocked: "blocked",
898 usageLimited: "usage_limited",
899 budgetLimited: "budget_limited",
900 complete: "complete",
901} as const satisfies Record<CodexThreadGoal["status"], OrchestrationV2ProviderGoal["status"]>;
902
903function providerGoalFromCodex(goal: CodexThreadGoal): OrchestrationV2ProviderGoal | null {
904 const objective = goal.objective.trim();
905 if (objective.length === 0) return null;
906 return {
907 objective,
908 status: CODEX_GOAL_STATUSES[goal.status],
909 tokensUsed: Math.max(0, goal.tokensUsed),
910 tokenBudget: goal.tokenBudget ?? null,
911 timeUsedSeconds: Math.max(0, goal.timeUsedSeconds),
912 };
913}
914
915const providerGoalsEqual = Schema.toEquivalence(Schema.NullOr(OrchestrationV2ProviderGoal));
916
917function describeCodexGoal(goal: OrchestrationV2ProviderGoal): string {
918 return `Goal ${goal.status.replace("_", " ")}: ${goal.objective}`;
919}
920
921// Codex starts the next goal turn milliseconds after the last one completes.
922// A run waits this long for it before settling, in case Codex declines.
923const CODEX_GOAL_CONTINUATION_GRACE = "5 seconds";
855924
856const providerTurnsForThread = (925const providerTurnsForThread = (
857 providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>,926 providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>,
...@@ -1070,6 +1139,16 @@ function isPersistentCodexDynamicTool(item: CodexDynamicToolItem): boolean {...@@ -1070,6 +1139,16 @@ function isPersistentCodexDynamicTool(item: CodexDynamicToolItem): boolean {
10701139
1071type CodexRootTerminalEvent = Extract<ProviderAdapterV2Event, { readonly type: "turn.terminal" }>;1140type CodexRootTerminalEvent = Extract<ProviderAdapterV2Event, { readonly type: "turn.terminal" }>;
10721141
1142/** A completed root turn whose run stays open for the goal turn Codex starts next. */
1143interface CodexGoalHold {
1144 readonly context: ActiveCodexTurnContext;
1145 readonly event: Extract<CodexRootTerminalEvent, { readonly failure: null }>;
1146 /** Sent when the hold resolves, so until then Stop and steering still target this turn. */
1147 readonly completedTurn: OrchestrationV2ProviderTurn;
1148 /** The turn Codex continued with, or undefined when the run settled instead. */
1149 readonly next: Deferred.Deferred<ActiveCodexTurnContext | undefined>;
1150}
1151
1073interface DeferredCodexRootTerminal {1152interface DeferredCodexRootTerminal {
1074 readonly context: ActiveCodexTurnContext;1153 readonly context: ActiveCodexTurnContext;
1075 readonly event: CodexRootTerminalEvent;1154 readonly event: CodexRootTerminalEvent;
...@@ -1704,10 +1783,53 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -1704,10 +1783,53 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
1704 // Native completion and the interrupt timeout share one finalization1783 // Native completion and the interrupt timeout share one finalization
1705 // path. Serialize the race so only one can publish terminal events.1784 // path. Serialize the race so only one can publish terminal events.
1706 const turnTerminalizationPermit = yield* Semaphore.make(1);1785 const turnTerminalizationPermit = yield* Semaphore.make(1);
1786 // Native goals by Codex thread, and the root provider thread snapshot
1787 // each goal change is written onto.
1788 const goalsByNativeThread = new Map<string, OrchestrationV2ProviderGoal | null>();
1789 const rootProviderThreads = new Map<string, OrchestrationV2ProviderThread>();
1790 // A completed root turn whose goal is still active. Its run stays open
1791 // for the turn Codex starts next, so goal phases do not read as Done.
1792 const goalHolds = new Map<string, CodexGoalHold>();
1793 // The root turns of each continued goal run, keyed by each of its turns.
1794 // A Stop or steer that names an earlier turn reaches the newest one, and
1795 // Stop also reaches work an earlier turn left running. Dropped when the
1796 // run settles.
1797 const goalRuns = new Map<ProviderTurnId, Array<ActiveCodexTurnContext>>();
1798 // `/goal` runs whose goal turns active after the first turn starts.
1799 const goalActivations = new Map<string, { stopped: boolean }>();
1800 // Stops in progress by native thread. Until a Stop resolves its target,
1801 // a held run must not settle as completed.
1802 const goalStops = new Map<string, number>();
1803 const latestGoalTurnId = (providerTurnId: ProviderTurnId) =>
1804 goalRuns.get(providerTurnId)?.at(-1)?.providerTurnId ?? providerTurnId;
17071805
1708 const emitProviderEvent = (event: ProviderAdapterV2Event) =>1806 const emitProviderEvent = (event: ProviderAdapterV2Event) =>
1709 Queue.offer(events, event).pipe(Effect.asVoid);1807 Queue.offer(events, event).pipe(Effect.asVoid);
17101808
1809 /** Writes the thread's current native goal onto its root provider thread. */
1810 const emitGoalUpdate = Effect.fnUntraced(function* (nativeThreadId: string) {
1811 const providerThread = rootProviderThreads.get(nativeThreadId);
1812 if (providerThread === undefined || !goalsByNativeThread.has(nativeThreadId)) return;
1813 const goal = goalsByNativeThread.get(nativeThreadId) ?? null;
1814 if (providerGoalsEqual(providerThread.goal ?? null, goal)) return;
1815 const updated = { ...providerThread, goal, updatedAt: yield* DateTime.now };
1816 rootProviderThreads.set(nativeThreadId, updated);
1817 yield* emitProviderEvent({
1818 type: "provider_thread.updated",
1819 driver: CODEX_PROVIDER,
1820 providerThread: updated,
1821 });
1822 });
1823
1824 const rememberRootProviderThread = Effect.fnUntraced(function* (
1825 providerThread: OrchestrationV2ProviderThread,
1826 ) {
1827 const nativeThreadId = providerThread.nativeThreadRef?.nativeId;
1828 if (nativeThreadId == null) return;
1829 rootProviderThreads.set(nativeThreadId, providerThread);
1830 yield* emitGoalUpdate(nativeThreadId);
1831 });
1832
1711 // Call only for new model-output activity. A local item/completed can1833 // Call only for new model-output activity. A local item/completed can
1712 // arrive while the upstream response stream is still retrying.1834 // arrive while the upstream response stream is still retrying.
1713 const completeProviderRetry = Effect.fn("CodexAdapterV2.completeProviderRetry")(function* (1835 const completeProviderRetry = Effect.fn("CodexAdapterV2.completeProviderRetry")(function* (
...@@ -1814,6 +1936,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -1814,6 +1936,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
1814 completedAt: null,1936 completedAt: null,
1815 },1937 },
1816 });1938 });
1939 yield* rememberRootProviderThread(input.turnInput.providerThread);
1817 return context;1940 return context;
1818 });1941 });
18191942
...@@ -3910,6 +4033,63 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -3910,6 +4033,63 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
3910 });4033 });
3911 return;4034 return;
3912 }4035 }
4036 // Codex continues an active goal on its own. The next turn joins the
4037 // run its previous turn belongs to.
4038 const goalHold = goalHolds.get(payload.threadId);
4039 if (goalHold !== undefined) {
4040 goalHolds.delete(payload.threadId);
4041 yield* emitProviderEvent({
4042 type: "provider_turn.updated",
4043 driver: CODEX_PROVIDER,
4044 threadId: goalHold.context.projectionThreadId,
4045 providerTurn: goalHold.completedTurn,
4046 });
4047 const next = yield* registerRootTurn({
4048 turnInput: {
4049 ...goalHold.context.input,
4050 providerTurnOrdinal: goalHold.context.providerTurnOrdinal + 1,
4051 },
4052 nativeTurnId: payload.turn.id,
4053 startedAt: codexTimestamp(payload.turn.startedAt),
4054 });
4055 const goalRun = goalRuns.get(goalHold.context.providerTurnId) ?? [goalHold.context];
4056 goalRun.push(next);
4057 goalRuns.set(goalHold.context.providerTurnId, goalRun);
4058 goalRuns.set(next.providerTurnId, goalRun);
4059 yield* Deferred.succeed(goalHold.next, next);
4060 return;
4061 }
4062 // A goal turn with no run to own it (Codex continued after the run
4063 // settled, or raced a Stop): stop it and pause the goal rather than
4064 // work out of sight.
4065 if (
4066 rootProviderThreads.has(payload.threadId) &&
4067 (goalsByNativeThread.get(payload.threadId)?.status === "active" ||
4068 goalStops.has(payload.threadId))
4069 ) {
4070 yield* Effect.logWarning("orchestration-v2.codex-goal-turn-without-run", {
4071 nativeThreadId: payload.threadId,
4072 nativeTurnId: payload.turn.id,
4073 });
4074 yield* client
4075 .request("thread/goal/set", { threadId: payload.threadId, status: "paused" })
4076 .pipe(
4077 Effect.catch((cause) =>
4078 Effect.logWarning("orchestration-v2.codex-goal-pause-failed", { cause }),
4079 ),
4080 Effect.andThen(
4081 client.request("turn/interrupt", {
4082 threadId: payload.threadId,
4083 turnId: payload.turn.id,
4084 }),
4085 ),
4086 Effect.catch((cause) =>
4087 Effect.logWarning("orchestration-v2.codex-goal-turn-stop-failed", { cause }),
4088 ),
4089 Effect.forkIn(scope),
4090 );
4091 return;
4092 }
3913 yield* rememberSubagentTurnStarted({4093 yield* rememberSubagentTurnStarted({
3914 nativeThreadId: payload.threadId,4094 nativeThreadId: payload.threadId,
3915 nativeTurnId: payload.turn.id,4095 nativeTurnId: payload.turn.id,
...@@ -3918,6 +4098,22 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -3918,6 +4098,22 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
3918 }).pipe(Effect.orDie, turnTerminalizationPermit.withPermits(1)),4098 }).pipe(Effect.orDie, turnTerminalizationPermit.withPermits(1)),
3919 );4099 );
39204100
4101 // A goal that stops being active ends any run held open for its next turn.
4102 yield* client.handleServerNotification("thread/goal/updated", (payload) =>
4103 Effect.gen(function* () {
4104 goalsByNativeThread.set(payload.threadId, providerGoalFromCodex(payload.goal));
4105 yield* emitGoalUpdate(payload.threadId);
4106 if (payload.goal.status !== "active") yield* releaseGoalHold(payload.threadId);
4107 }).pipe(turnTerminalizationPermit.withPermits(1)),
4108 );
4109 yield* client.handleServerNotification("thread/goal/cleared", (payload) =>
4110 Effect.gen(function* () {
4111 goalsByNativeThread.set(payload.threadId, null);
4112 yield* emitGoalUpdate(payload.threadId);
4113 yield* releaseGoalHold(payload.threadId);
4114 }).pipe(turnTerminalizationPermit.withPermits(1)),
4115 );
4116
3921 yield* client.handleServerNotification("error", (payload) =>4117 yield* client.handleServerNotification("error", (payload) =>
3922 Effect.gen(function* () {4118 Effect.gen(function* () {
3923 const context = yield* awaitActiveTurn(payload.turnId);4119 const context = yield* awaitActiveTurn(payload.turnId);
...@@ -5011,6 +5207,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5011,6 +5207,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5011 }5207 }
5012 : event;5208 : event;
5013 yield* emitProviderEvent(current);5209 yield* emitProviderEvent(current);
5210 for (const turn of goalRuns.get(context.providerTurnId) ?? []) {
5211 goalRuns.delete(turn.providerTurnId);
5212 }
5213 // Later goal updates must not mark the settled thread active again.
5214 const nativeThreadId = context.providerThread.nativeThreadRef?.nativeId;
5215 const rootProviderThread =
5216 nativeThreadId == null ? undefined : rootProviderThreads.get(nativeThreadId);
5217 if (nativeThreadId != null && rootProviderThread !== undefined) {
5218 rootProviderThreads.set(nativeThreadId, { ...rootProviderThread, status: "idle" });
5219 }
5014 if (current.status === "failed" && current.failure.class === "usage_limit") {5220 if (current.status === "failed" && current.failure.class === "usage_limit") {
5015 const item = makeProviderFailureTurnItem({5221 const item = makeProviderFailureTurnItem({
5016 idAllocator,5222 idAllocator,
...@@ -5038,6 +5244,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5038,6 +5244,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5038 readonly failureMessage?: string;5244 readonly failureMessage?: string;
5039 readonly failureCode?: string | null;5245 readonly failureCode?: string | null;
5040 readonly providerRetry?: ActiveCodexProviderRetry;5246 readonly providerRetry?: ActiveCodexProviderRetry;
5247 readonly goalHoldTurn?: OrchestrationV2ProviderTurn;
5041 }) {5248 }) {
5042 const event = yield* makeRootTerminalEvent(input);5249 const event = yield* makeRootTerminalEvent(input);
5043 const hasActiveDescendants = Array.from((yield* Ref.get(activeTurns)).values()).some(5250 const hasActiveDescendants = Array.from((yield* Ref.get(activeTurns)).values()).some(
...@@ -5051,10 +5258,60 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5051,10 +5258,60 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5051 });5258 });
5052 return;5259 return;
5053 }5260 }
5261 const nativeThreadId = input.context.providerThread.nativeThreadRef?.nativeId;
5262 if (
5263 input.goalHoldTurn !== undefined &&
5264 event.status === "completed" &&
5265 nativeThreadId != null
5266 ) {
5267 goalHolds.set(nativeThreadId, {
5268 context: input.context,
5269 event,
5270 completedTurn: input.goalHoldTurn,
5271 next: yield* Deferred.make<ActiveCodexTurnContext | undefined>(),
5272 });
5273 yield* Effect.sleep(CODEX_GOAL_CONTINUATION_GRACE).pipe(
5274 Effect.andThen(
5275 turnTerminalizationPermit.withPermits(1)(releaseGoalHold(nativeThreadId, event)),
5276 ),
5277 Effect.forkIn(scope),
5278 );
5279 return;
5280 }
5054 yield* emitRootTerminal(input.context, event);5281 yield* emitRootTerminal(input.context, event);
5055 },5282 },
5056 );5283 );
50575284
5285 /**
5286 * Settles a held goal run with its last turn. Call with the
5287 * terminalization permit, after removing the hold from `goalHolds`.
5288 */
5289 const settleGoalHold = Effect.fnUntraced(function* (
5290 hold: CodexGoalHold,
5291 status: "completed" | "interrupted",
5292 ) {
5293 yield* emitProviderEvent({
5294 type: "provider_turn.updated",
5295 driver: CODEX_PROVIDER,
5296 threadId: hold.context.projectionThreadId,
5297 providerTurn: hold.completedTurn,
5298 });
5299 yield* emitRootTerminal(hold.context, { ...hold.event, status });
5300 yield* Deferred.succeed(hold.next, undefined);
5301 });
5302
5303 /** Settles a held goal run unless Stop owns it or a newer hold replaced it. */
5304 const releaseGoalHold = Effect.fnUntraced(function* (
5305 nativeThreadId: string,
5306 expected?: CodexRootTerminalEvent,
5307 ) {
5308 const hold = goalHolds.get(nativeThreadId);
5309 if (hold === undefined || goalStops.has(nativeThreadId)) return;
5310 if (expected !== undefined && hold.event !== expected) return;
5311 goalHolds.delete(nativeThreadId);
5312 yield* settleGoalHold(hold, "completed");
5313 });
5314
5058 const flushReadyRootTerminals = Effect.fn("CodexAdapterV2.flushReadyRootTerminals")(5315 const flushReadyRootTerminals = Effect.fn("CodexAdapterV2.flushReadyRootTerminals")(
5059 function* () {5316 function* () {
5060 const activeTurnContexts = Array.from((yield* Ref.get(activeTurns)).values());5317 const activeTurnContexts = Array.from((yield* Ref.get(activeTurns)).values());
...@@ -5141,35 +5398,49 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5141,35 +5398,49 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5141 for (const [key, part] of reasoningParts) {5398 for (const [key, part] of reasoningParts) {
5142 if (part.turnId === input.nativeTurnId) reasoningParts.delete(key);5399 if (part.turnId === input.nativeTurnId) reasoningParts.delete(key);
5143 }5400 }
5144 yield* emitProviderEvent({5401 const nativeThreadId = input.context.providerThread.nativeThreadRef?.nativeId;
5145 type: "provider_turn.updated",5402 // Codex continues an active goal with another turn, so the run stays
5146 driver: CODEX_PROVIDER,5403 // open and this turn reads as running until that turn starts.
5147 threadId: input.context.projectionThreadId,5404 const activation =
5148 providerTurn: {5405 nativeThreadId == null ? undefined : goalActivations.get(nativeThreadId);
5149 id: input.context.providerTurnId,5406 const holdsForGoal =
5150 providerThreadId: input.context.providerThread.id,5407 input.context.subagent === null &&
5151 nodeId: input.context.providerNodeId,5408 input.status === "completed" &&
5152 runAttemptId:5409 nativeThreadId != null &&
5153 input.context.subagent === null ? input.context.input.attemptId : null,5410 (goalsByNativeThread.get(nativeThreadId)?.status === "active" ||
5154 nativeTurnRef: {5411 (activation !== undefined && !activation.stopped));
5155 driver: CODEX_PROVIDER,5412 const completedTurn: OrchestrationV2ProviderTurn = {
5156 nativeId: input.nativeTurnId,5413 id: input.context.providerTurnId,
5157 strength: "strong",5414 providerThreadId: input.context.providerThread.id,
5158 },5415 nodeId: input.context.providerNodeId,
5159 ordinal: input.context.providerTurnOrdinal,5416 runAttemptId:
5160 status: input.status,5417 input.context.subagent === null ? input.context.input.attemptId : null,
5161 startedAt: input.context.startedAt,5418 nativeTurnRef: {
5162 completedAt: input.completedAt,5419 driver: CODEX_PROVIDER,
5163 turnTokenUsage: completeCodexTurnTokenUsage(5420 nativeId: input.nativeTurnId,
5164 usageStateForThread(5421 strength: "strong",
5165 input.context.providerThread.nativeThreadRef?.nativeId ??
5166 String(input.context.providerThread.id),
5167 ),
5168 input.nativeTurnId,
5169 input.status === "completed",
5170 ),
5171 },5422 },
5172 });5423 ordinal: input.context.providerTurnOrdinal,
5424 status: input.status,
5425 startedAt: input.context.startedAt,
5426 completedAt: input.completedAt,
5427 turnTokenUsage: completeCodexTurnTokenUsage(
5428 usageStateForThread(
5429 input.context.providerThread.nativeThreadRef?.nativeId ??
5430 String(input.context.providerThread.id),
5431 ),
5432 input.nativeTurnId,
5433 input.status === "completed",
5434 ),
5435 };
5436 if (!holdsForGoal) {
5437 yield* emitProviderEvent({
5438 type: "provider_turn.updated",
5439 driver: CODEX_PROVIDER,
5440 threadId: input.context.projectionThreadId,
5441 providerTurn: completedTurn,
5442 });
5443 }
5173 if (input.context.subagent !== null) {5444 if (input.context.subagent !== null) {
5174 yield* emitProviderEvent({5445 yield* emitProviderEvent({
5175 type: "node.updated",5446 type: "node.updated",
...@@ -5251,6 +5522,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5251,6 +5522,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5251 yield* emitOrDeferRootTerminal({5522 yield* emitOrDeferRootTerminal({
5252 ...input,5523 ...input,
5253 ...(providerRetry === undefined ? {} : { providerRetry }),5524 ...(providerRetry === undefined ? {} : { providerRetry }),
5525 ...(holdsForGoal ? { goalHoldTurn: completedTurn } : {}),
5254 });5526 });
5255 }5527 }
5256 const waiter = (yield* Ref.get(turnWaiters)).get(input.nativeTurnId);5528 const waiter = (yield* Ref.get(turnWaiters)).get(input.nativeTurnId);
...@@ -5353,6 +5625,346 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5353,6 +5625,346 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5353 }),5625 }),
5354 );5626 );
53555627
5628 /**
5629 * Settles a `/goal` run that started no Codex turn. Its reply is plain
5630 * assistant text, as Claude's own `/goal` output is.
5631 */
5632 const completeGoalCommandTurn = Effect.fnUntraced(function* (
5633 turnInput: ProviderAdapterV2TurnInput,
5634 reply: string,
5635 ) {
5636 const now = yield* DateTime.now;
5637 const nativeId = `goal-command:${turnInput.attemptId}`;
5638 const context: ActiveCodexTurnContext = {
5639 input: turnInput,
5640 projectionAppThread: turnInput.appThread,
5641 projectionThreadId: turnInput.threadId,
5642 projectionRunId: turnInput.runId,
5643 nativeTurnId: nativeId,
5644 providerThread: turnInput.providerThread,
5645 providerTurnId: idAllocator.derive.providerTurn({
5646 driver: CODEX_PROVIDER,
5647 nativeTurnId: nativeId,
5648 }),
5649 providerTurnOrdinal: turnInput.providerTurnOrdinal,
5650 providerNodeId: turnInput.rootNodeId,
5651 providerNodeKind: "root_turn",
5652 providerNodeStartedAt: now,
5653 itemParentNodeId: turnInput.rootNodeId,
5654 rootNodeId: turnInput.rootNodeId,
5655 subagent: null,
5656 startedAt: now,
5657 itemPositions: new Map(),
5658 };
5659 const providerTurn = {
5660 id: context.providerTurnId,
5661 providerThreadId: turnInput.providerThread.id,
5662 nodeId: turnInput.rootNodeId,
5663 runAttemptId: turnInput.attemptId,
5664 nativeTurnRef: null,
5665 ordinal: turnInput.providerTurnOrdinal,
5666 status: "running",
5667 startedAt: now,
5668 completedAt: null,
5669 } satisfies OrchestrationV2ProviderTurn;
5670 yield* emitProviderEvent({
5671 type: "provider_turn.updated",
5672 driver: CODEX_PROVIDER,
5673 threadId: turnInput.threadId,
5674 providerTurn,
5675 });
5676 yield* rememberRootProviderThread(turnInput.providerThread);
5677 const artifacts = yield* buildAgentMessageArtifacts(
5678 context,
5679 { id: nativeId, text: reply },
5680 true,
5681 );
5682 yield* emitProviderEvent({
5683 type: "node.updated",
5684 driver: CODEX_PROVIDER,
5685 node: artifacts.node,
5686 });
5687 yield* emitProviderEvent({
5688 type: "message.updated",
5689 driver: CODEX_PROVIDER,
5690 message: artifacts.message,
5691 });
5692 yield* emitProviderEvent({
5693 type: "turn_item.updated",
5694 driver: CODEX_PROVIDER,
5695 turnItem: artifacts.turnItem,
5696 });
5697 yield* emitProviderEvent({
5698 type: "provider_turn.updated",
5699 driver: CODEX_PROVIDER,
5700 threadId: turnInput.threadId,
5701 providerTurn: { ...providerTurn, status: "completed", completedAt: now },
5702 });
5703 yield* emitRootTerminal(context, {
5704 type: "turn.terminal",
5705 driver: CODEX_PROVIDER,
5706 providerThreadId: turnInput.providerThread.id,
5707 providerTurnId: context.providerTurnId,
5708 runOrdinal: turnInput.runOrdinal,
5709 status: "completed",
5710 failure: null,
5711 threadDisposition: "reusable",
5712 });
5713 });
5714
5715 /** The goal hold whose run still reports this provider turn as running. */
5716 const heldGoalTurn = (
5717 providerThread: OrchestrationV2ProviderThread,
5718 providerTurnId: ProviderTurnId,
5719 ) => {
5720 const nativeThreadId = providerThread.nativeThreadRef?.nativeId;
5721 const hold = nativeThreadId == null ? undefined : goalHolds.get(nativeThreadId);
5722 return hold?.context.providerTurnId === providerTurnId && nativeThreadId != null
5723 ? { nativeThreadId, hold }
5724 : undefined;
5725 };
5726
5727 /**
5728 * Like the Codex TUI, Stop pauses an active goal first so Codex starts
5729 * no further goal turn. Codex can end or continue the turn while the
5730 * pause is in flight, so the target resolves after it: a held run
5731 * settles here and leaves only its retained work to stop, and a
5732 * continued run moves Stop to its newest turn. `goalTurns` lists the
5733 * run's root turns, seen before and after the pause, whose descendants
5734 * Stop also reaches.
5735 */
5736 const resolveGoalStopTarget = Effect.fnUntraced(function* (
5737 turnInput: ProviderAdapterV2InterruptInput,
5738 ) {
5739 const goalThreadId = turnInput.providerThread.nativeThreadRef?.nativeId;
5740 if (goalThreadId == null) {
5741 return { turnInput, goalTurns: [] as ReadonlyArray<ActiveCodexTurnContext> };
5742 }
5743 goalStops.set(goalThreadId, (goalStops.get(goalThreadId) ?? 0) + 1);
5744 return yield* Effect.gen(function* () {
5745 // The run can settle while the pause is in flight, which drops its record.
5746 const requestedTurnId = latestGoalTurnId(turnInput.providerTurnId);
5747 const heldBefore = goalHolds.get(goalThreadId)?.context;
5748 const turnsBefore = [
5749 ...(goalRuns.get(requestedTurnId) ?? []),
5750 ...Array.from((yield* Ref.get(activeTurns)).values()).filter(
5751 (context) => context.providerTurnId === requestedTurnId,
5752 ),
5753 ...(heldBefore?.providerTurnId === requestedTurnId ? [heldBefore] : []),
5754 ];
5755 const activation = goalActivations.get(goalThreadId);
5756 if (activation !== undefined) activation.stopped = true;
5757 if (goalsByNativeThread.get(goalThreadId)?.status === "active") {
5758 yield* client
5759 .request("thread/goal/set", { threadId: goalThreadId, status: "paused" })
5760 .pipe(
5761 Effect.tap(({ goal }) =>
5762 Effect.sync(() =>
5763 goalsByNativeThread.set(goalThreadId, providerGoalFromCodex(goal)),
5764 ),
5765 ),
5766 Effect.catch((cause) =>
5767 Effect.logWarning("orchestration-v2.codex-goal-pause-failed", { cause }),
5768 ),
5769 );
5770 }
5771 return yield* turnTerminalizationPermit.withPermits(1)(
5772 Effect.gen(function* () {
5773 const providerTurnId = latestGoalTurnId(turnInput.providerTurnId);
5774 const hold = goalHolds.get(goalThreadId);
5775 const held = hold !== undefined && hold.context.providerTurnId === providerTurnId;
5776 const goalTurns: ReadonlyArray<ActiveCodexTurnContext> = Array.from(
5777 new Set([
5778 ...turnsBefore,
5779 ...(goalRuns.get(providerTurnId) ?? []),
5780 ...(held ? [hold.context] : []),
5781 ]),
5782 );
5783 if (!held) return { turnInput: { ...turnInput, providerTurnId }, goalTurns };
5784 goalHolds.delete(goalThreadId);
5785 yield* settleGoalHold(hold, "interrupted");
5786 return {
5787 turnInput: { ...turnInput, providerTurnId, requestRuntimeRestart: true },
5788 goalTurns,
5789 };
5790 }),
5791 );
5792 }).pipe(
5793 Effect.ensuring(
5794 Effect.sync(() => {
5795 const stops = (goalStops.get(goalThreadId) ?? 1) - 1;
5796 if (stops > 0) goalStops.set(goalThreadId, stops);
5797 else goalStops.delete(goalThreadId);
5798 }),
5799 ),
5800 );
5801 });
5802
5803 /** Starts a native turn with this run's full turn configuration. */
5804 const startNativeTurn = (
5805 turnInput: ProviderAdapterV2TurnInput,
5806 codexInput: ReadonlyArray<CodexSchema.V2TurnStartParams__UserInput>,
5807 ) =>
5808 Effect.gen(function* () {
5809 const threadId = yield* getNativeThreadId(turnInput.providerThread);
5810 const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId);
5811 const turnStartParams = yield* buildCodexTurnStartParams({
5812 nativeThreadId: threadId,
5813 codexInput,
5814 runtimePolicy: turnInput.runtimePolicy,
5815 modelSelection: turnInput.modelSelection,
5816 hasT3Mcp: mcpSession !== undefined,
5817 browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true,
5818 deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false,
5819 omitServiceTier: adapterOptions.resolveRuntime !== undefined,
5820 });
5821 yield* Ref.update(pendingRootTurns, (current) => {
5822 const updated = new Map(current);
5823 updated.set(threadId, turnInput);
5824 return updated;
5825 });
5826 yield* Ref.update(additionalContextByThread, (current) => {
5827 const next = new Map(current);
5828 if (turnStartParams.additionalContext)
5829 next.set(threadId, turnStartParams.additionalContext);
5830 else next.delete(threadId);
5831 return next;
5832 });
5833 const started = yield* client.request("turn/start", turnStartParams);
5834 yield* registerRootTurn({
5835 turnInput,
5836 nativeTurnId: started.turn.id,
5837 startedAt: codexTimestamp(started.turn.startedAt),
5838 waitForNativeStart: started.turn.startedAt === null,
5839 });
5840 }).pipe(
5841 Effect.ensuring(
5842 Effect.flatMap(getNativeThreadId(turnInput.providerThread), (threadId) =>
5843 Ref.update(pendingRootTurns, (current) => {
5844 const updated = new Map(current);
5845 updated.delete(threadId);
5846 return updated;
5847 }),
5848 ).pipe(Effect.ignore),
5849 ),
5850 );
5851
5852 /**
5853 * Runs `/goal` through Codex's native goal API, as the Codex TUI does.
5854 * Setting or resuming a goal starts the first goal turn with this run's
5855 * turn configuration (approvals, sandbox, model, plan mode), since a
5856 * turn Codex starts on its own reuses the last turn's settings. Codex
5857 * continues later goal turns itself. Other commands settle with a reply.
5858 */
5859 const runGoalCommand = (turnInput: ProviderAdapterV2TurnInput, command: CodexGoalCommand) =>
5860 Effect.gen(function* () {
5861 const threadId = yield* getNativeThreadId(turnInput.providerThread);
5862 // Goal notifications during the command belong to this run's snapshot.
5863 rootProviderThreads.set(threadId, turnInput.providerThread);
5864 const readGoal = client
5865 .request("thread/goal/get", { threadId })
5866 .pipe(
5867 Effect.map((response) =>
5868 response.goal == null ? null : providerGoalFromCodex(response.goal),
5869 ),
5870 );
5871 const current = yield* readGoal;
5872 goalsByNativeThread.set(threadId, current);
5873 if (command.type === "show") {
5874 return yield* completeGoalCommandTurn(
5875 turnInput,
5876 current === null ? "No goal is set." : describeCodexGoal(current),
5877 );
5878 }
5879 if (command.type === "clear") {
5880 const { cleared } = yield* client.request("thread/goal/clear", { threadId });
5881 goalsByNativeThread.set(threadId, null);
5882 return yield* completeGoalCommandTurn(
5883 turnInput,
5884 cleared ? "Goal cleared." : "No goal to clear.",
5885 );
5886 }
5887 if (current === null && command.type !== "set") {
5888 return yield* completeGoalCommandTurn(turnInput, "No goal is set.");
5889 }
5890 if (command.type === "pause") {
5891 const { goal } = yield* client.request("thread/goal/set", {
5892 threadId,
5893 status: "paused",
5894 });
5895 goalsByNativeThread.set(threadId, providerGoalFromCodex(goal));
5896 return yield* completeGoalCommandTurn(
5897 turnInput,
5898 "Goal paused. Send /goal resume to continue.",
5899 );
5900 }
5901 if (command.type === "set") {
5902 // A new objective replaces the goal and its accounting, like the TUI.
5903 if (current !== null) yield* client.request("thread/goal/clear", { threadId });
5904 // Paused until our turn runs, so Codex does not start one first.
5905 const { goal } = yield* client.request("thread/goal/set", {
5906 threadId,
5907 objective: command.objective,
5908 status: "paused",
5909 });
5910 goalsByNativeThread.set(threadId, providerGoalFromCodex(goal));
5911 }
5912 // Until the goal is active, a fast first turn still holds the run,
5913 // and Stop cancels the activation.
5914 const activation = { stopped: false };
5915 goalActivations.set(threadId, activation);
5916 yield* Effect.gen(function* () {
5917 // The first goal turn carries the objective; a resume continues from history.
5918 yield* startNativeTurn(
5919 turnInput,
5920 command.type === "set"
5921 ? yield* toCodexInput({
5922 ...turnInput,
5923 message: { ...turnInput.message, text: command.objective },
5924 })
5925 : [],
5926 );
5927 if (activation.stopped) return;
5928 const { goal } = yield* client.request("thread/goal/set", {
5929 threadId,
5930 status: "active",
5931 });
5932 goalsByNativeThread.set(threadId, providerGoalFromCodex(goal));
5933 if (activation.stopped) {
5934 const paused = yield* client.request("thread/goal/set", {
5935 threadId,
5936 status: "paused",
5937 });
5938 goalsByNativeThread.set(threadId, providerGoalFromCodex(paused.goal));
5939 }
5940 }).pipe(
5941 Effect.ensuring(
5942 turnTerminalizationPermit.withPermits(1)(
5943 Effect.gen(function* () {
5944 if (goalActivations.get(threadId) === activation) {
5945 goalActivations.delete(threadId);
5946 }
5947 // A run held for an activation that did not happen settles now.
5948 if (goalsByNativeThread.get(threadId)?.status !== "active") {
5949 yield* releaseGoalHold(threadId);
5950 }
5951 }),
5952 ),
5953 ),
5954 );
5955 }).pipe(
5956 Effect.mapError(
5957 (cause) =>
5958 new ProviderAdapterTurnStartError({
5959 driver: CODEX_PROVIDER,
5960 threadId: turnInput.threadId,
5961 providerThreadId: turnInput.providerThread.id,
5962 runId: turnInput.runId,
5963 cause,
5964 }),
5965 ),
5966 );
5967
5356 const runtime: ProviderAdapterV2SessionRuntime = {5968 const runtime: ProviderAdapterV2SessionRuntime = {
5357 instanceId: adapterOptions.instanceId,5969 instanceId: adapterOptions.instanceId,
5358 driver: CODEX_PROVIDER,5970 driver: CODEX_PROVIDER,
...@@ -5473,6 +6085,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5473,6 +6085,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5473 ),6085 ),
5474 Effect.flatMap(decodeCodexResumeMetadata),6086 Effect.flatMap(decodeCodexResumeMetadata),
5475 );6087 );
6088 // Codex follows a resume with a goal snapshot notification; the
6089 // run's first turn writes it if it differs from the stored goal.
5476 return {6090 return {
5477 ...threadInput.providerThread,6091 ...threadInput.providerThread,
5478 providerSessionId: input.providerSessionId,6092 providerSessionId: input.providerSessionId,
...@@ -5552,61 +6166,21 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5552,61 +6166,21 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5552 }),6166 }),
5553 ),6167 ),
5554 ),6168 ),
5555 startTurn: (turnInput) =>6169 startTurn: (turnInput) => {
5556 Effect.gen(function* () {6170 const goalCommand =
5557 const threadId = yield* getNativeThreadId(turnInput.providerThread);6171 turnInput.message.attachments.length === 0 &&
55586172 turnInput.restartContinuationOfRunId === undefined
5559 const codexInput =6173 ? parseCodexGoalCommand(turnInput.message.text)
6174 : null;
6175 if (goalCommand !== null) return runGoalCommand(turnInput, goalCommand);
6176 return Effect.gen(function* () {
6177 yield* startNativeTurn(
6178 turnInput,
5560 turnInput.restartContinuationOfRunId === undefined6179 turnInput.restartContinuationOfRunId === undefined
5561 ? yield* toCodexInput(turnInput)6180 ? yield* toCodexInput(turnInput)
5562 : [];6181 : [],
5563 const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId);6182 );
5564 const turnStartParams = yield* buildCodexTurnStartParams({
5565 nativeThreadId: threadId,
5566 codexInput,
5567 runtimePolicy: turnInput.runtimePolicy,
5568 modelSelection: turnInput.modelSelection,
5569 hasT3Mcp: mcpSession !== undefined,
5570 browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true,
5571 deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false,
5572 omitServiceTier: adapterOptions.resolveRuntime !== undefined,
5573 });
5574 yield* Ref.update(pendingRootTurns, (current) => {
5575 const updated = new Map(current);
5576 updated.set(threadId, turnInput);
5577 return updated;
5578 });
5579 yield* Ref.update(additionalContextByThread, (current) => {
5580 const next = new Map(current);
5581 if (turnStartParams.additionalContext)
5582 next.set(threadId, turnStartParams.additionalContext);
5583 else next.delete(threadId);
5584 return next;
5585 });
5586 const started = yield* client.request("turn/start", turnStartParams);
5587 const nativeTurnId = started.turn.id;
5588 const startedAt = codexTimestamp(started.turn.startedAt);
5589 yield* registerRootTurn({
5590 turnInput,
5591 nativeTurnId,
5592 startedAt,
5593 waitForNativeStart: started.turn.startedAt === null,
5594 });
5595 yield* Ref.update(pendingRootTurns, (current) => {
5596 const updated = new Map(current);
5597 updated.delete(threadId);
5598 return updated;
5599 });
5600 }).pipe(6183 }).pipe(
5601 Effect.ensuring(
5602 Effect.flatMap(getNativeThreadId(turnInput.providerThread), (threadId) =>
5603 Ref.update(pendingRootTurns, (current) => {
5604 const updated = new Map(current);
5605 updated.delete(threadId);
5606 return updated;
5607 }),
5608 ).pipe(Effect.ignore),
5609 ),
5610 Effect.mapError(6184 Effect.mapError(
5611 (cause) =>6185 (cause) =>
5612 new ProviderAdapterTurnStartError({6186 new ProviderAdapterTurnStartError({
...@@ -5617,12 +6191,20 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5617,12 +6191,20 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5617 cause,6191 cause,
5618 }),6192 }),
5619 ),6193 ),
5620 ),6194 );
6195 },
5621 steerTurn: (turnInput) =>6196 steerTurn: (turnInput) =>
5622 Effect.gen(function* () {6197 Effect.gen(function* () {
5623 const threadId = yield* getNativeThreadId(turnInput.providerThread);6198 const threadId = yield* getNativeThreadId(turnInput.providerThread);
6199 // Between goal turns, steer the turn Codex continues with.
6200 const latestTurnId = latestGoalTurnId(turnInput.providerTurnId);
6201 const held = heldGoalTurn(turnInput.providerThread, latestTurnId);
6202 const providerTurnId =
6203 held === undefined
6204 ? latestTurnId
6205 : ((yield* Deferred.await(held.hold.next))?.providerTurnId ?? latestTurnId);
5624 const activeTurn = Array.from((yield* Ref.get(activeTurns)).values()).find(6206 const activeTurn = Array.from((yield* Ref.get(activeTurns)).values()).find(
5625 (candidate) => candidate.providerTurnId === turnInput.providerTurnId,6207 (candidate) => candidate.providerTurnId === providerTurnId,
5626 );6208 );
5627 if (activeTurn === undefined) {6209 if (activeTurn === undefined) {
5628 return yield* toProtocolError(6210 return yield* toProtocolError(
...@@ -5665,8 +6247,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5665,8 +6247,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5665 }),6247 }),
5666 ),6248 ),
5667 ),6249 ),
5668 interruptTurn: (turnInput) =>6250 interruptTurn: (requestedInput) =>
5669 Effect.gen(function* () {6251 Effect.gen(function* () {
6252 const { turnInput, goalTurns } = yield* resolveGoalStopTarget(requestedInput);
5670 const [activeTurnContexts, settledTurnContexts] =6253 const [activeTurnContexts, settledTurnContexts] =
5671 yield* turnTerminalizationPermit.withPermits(1)(6254 yield* turnTerminalizationPermit.withPermits(1)(
5672 Effect.gen(function* () {6255 Effect.gen(function* () {
...@@ -5685,28 +6268,36 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5685,28 +6268,36 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5685 (candidate) => candidate.providerThread.id === turnInput.providerThread.id,6268 (candidate) => candidate.providerThread.id === turnInput.providerThread.id,
5686 )6269 )
5687 : undefined);6270 : undefined);
5688 if (activeTurn === undefined) {6271 // A goal run spans several root turns, and work an earlier one
5689 // Stop on a settled turn this process retains nothing for6272 // started can outlive it.
5690 // (released, restarted, or every command already reported).6273 const lineageRoots = Array.from(
5691 if (turnInput.requestRuntimeRestart === true) return;6274 new Set([...(activeTurn === undefined ? [] : [activeTurn]), ...goalTurns]),
5692 return yield* toProtocolError(6275 );
5693 `Provider turn ${turnInput.providerTurnId} is not active and cannot be interrupted.`,6276 const inLineage = (context: ActiveCodexTurnContext) =>
6277 lineageRoots.some(
6278 (root) => context === root || isDescendantCodexTurn(context, root),
5694 );6279 );
5695 }
5696 const interruptTargetContexts = [6280 const interruptTargetContexts = [
5697 activeTurn,6281 ...(activeTurn === undefined ? [] : [activeTurn]),
5698 ...activeTurnContexts.filter(6282 ...activeTurnContexts.filter(
5699 (candidate) =>6283 (candidate) => candidate !== activeTurn && inLineage(candidate),
5700 candidate !== activeTurn && isDescendantCodexTurn(candidate, activeTurn),
5701 ),6284 ),
5702 ...settledTurnContexts.filter(6285 ...settledTurnContexts.filter(
5703 (candidate) =>6286 (candidate) =>
5704 candidate !== activeTurn &&6287 candidate !== activeTurn &&
5705 ((turnInput.requestRuntimeRestart === true &&6288 ((turnInput.requestRuntimeRestart === true &&
5706 candidate.providerThread.id === turnInput.providerThread.id) ||6289 candidate.providerThread.id === turnInput.providerThread.id) ||
5707 isDescendantCodexTurn(candidate, activeTurn)),6290 inLineage(candidate)),
5708 ),6291 ),
5709 ];6292 ];
6293 if (interruptTargetContexts.length === 0) {
6294 // Stop on a settled turn this process retains nothing for
6295 // (released, restarted, or every command already reported).
6296 if (turnInput.requestRuntimeRestart === true) return;
6297 return yield* toProtocolError(
6298 `Provider turn ${turnInput.providerTurnId} is not active and cannot be interrupted.`,
6299 );
6300 }
5710 const interruptTargets: Array<{6301 const interruptTargets: Array<{
5711 readonly context: ActiveCodexTurnContext;6302 readonly context: ActiveCodexTurnContext;
5712 readonly completion: Deferred.Deferred<void, never>;6303 readonly completion: Deferred.Deferred<void, never>;
...@@ -5769,8 +6360,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5769,8 +6360,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5769 const activeLineage = yield* turnTerminalizationPermit.withPermits(1)(6360 const activeLineage = yield* turnTerminalizationPermit.withPermits(1)(
5770 Effect.gen(function* () {6361 Effect.gen(function* () {
5771 const lineage = Array.from((yield* Ref.get(activeTurns)).values()).filter(6362 const lineage = Array.from((yield* Ref.get(activeTurns)).values()).filter(
5772 (context) =>6363 inLineage,
5773 context === activeTurn || isDescendantCodexTurn(context, activeTurn),
5774 );6364 );
5775 for (const context of lineage) {6365 for (const context of lineage) {
5776 if (trackedInterruptNativeTurnIds.has(context.nativeTurnId)) {6366 if (trackedInterruptNativeTurnIds.has(context.nativeTurnId)) {
...@@ -5847,8 +6437,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5847,8 +6437,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5847 Effect.gen(function* () {6437 Effect.gen(function* () {
5848 const lineage = Array.from((yield* Ref.get(activeTurns)).values()).filter(6438 const lineage = Array.from((yield* Ref.get(activeTurns)).values()).filter(
5849 (context) =>6439 (context) =>
5850 context !== activeTurn &&6440 !lineageRoots.includes(context) &&
5851 isDescendantCodexTurn(context, activeTurn) &&6441 inLineage(context) &&
5852 !trackedInterruptNativeTurnIds.has(context.nativeTurnId),6442 !trackedInterruptNativeTurnIds.has(context.nativeTurnId),
5853 );6443 );
5854 for (const context of lineage) {6444 for (const context of lineage) {
...@@ -6038,8 +6628,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -6038,8 +6628,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
6038 (cause) =>6628 (cause) =>
6039 new ProviderAdapterInterruptError({6629 new ProviderAdapterInterruptError({
6040 driver: CODEX_PROVIDER,6630 driver: CODEX_PROVIDER,
6041 providerThreadId: turnInput.providerThread.id,6631 providerThreadId: requestedInput.providerThread.id,
6042 providerTurnId: turnInput.providerTurnId,6632 providerTurnId: requestedInput.providerTurnId,
6043 cause,6633 cause,
6044 }),6634 }),
6045 ),6635 ),
apps/server/src/orchestration-v2/CheckpointRollbackService.ts+5-5
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 CheckpointId,2 CheckpointId,
3 CheckpointScopeId,3 CheckpointScopeId,
4 latestProviderTurnForAttempt,
4 type OrchestrationV2DomainEvent,5 type OrchestrationV2DomainEvent,
5 ProviderThreadId,6 ProviderThreadId,
6 ThreadId,7 ThreadId,
...@@ -233,11 +234,10 @@ export const layer: Layer.Layer<...@@ -233,11 +234,10 @@ export const layer: Layer.Layer<
233 const targetAttempt = projection.attempts.find(234 const targetAttempt = projection.attempts.find(
234 (attempt) => attempt.id === targetRun?.activeAttemptId,235 (attempt) => attempt.id === targetRun?.activeAttemptId,
235 );236 );
236 const targetTurn = projection.providerTurns.find(237 // A goal run can span several native turns; roll back to its last.
237 (turn) =>238 const targetTurn =
238 turn.id === targetAttempt?.providerTurnId ||239 latestProviderTurnForAttempt(projection.providerTurns, targetAttempt?.id) ??
239 turn.runAttemptId === targetAttempt?.id,240 projection.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId);
240 );
241 if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) {241 if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) {
242 return yield* new CheckpointRollbackExecutionError({242 return yield* new CheckpointRollbackExecutionError({
243 reason: "provider-turn-unavailable",243 reason: "provider-turn-unavailable",
apps/server/src/orchestration-v2/NotificationMailbox.ts+7-4
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import type { MessageId, OrchestrationV2ThreadProjection } from "@t3tools/contracts";1import {
2 latestProviderTurnForAttempt,
3 type MessageId,
4 type OrchestrationV2ThreadProjection,
5} from "@t3tools/contracts";
26
3/**7/**
4 * A persisted steer without an acceptance receipt can be retried as a continuation.8 * A persisted steer without an acceptance receipt can be retried as a continuation.
...@@ -16,8 +20,7 @@ export function isUndeliveredMailboxSteer(...@@ -16,8 +20,7 @@ export function isUndeliveredMailboxSteer(
16 run !== undefined &&20 run !== undefined &&
17 run.userMessageId !== message.id &&21 run.userMessageId !== message.id &&
18 (["completed", "failed", "interrupted", "cancelled", "rolled_back"].includes(run.status) ||22 (["completed", "failed", "interrupted", "cancelled", "rolled_back"].includes(run.status) ||
19 projection.providerTurns.some(23 latestProviderTurnForAttempt(projection.providerTurns, run.activeAttemptId)?.status ===
20 (turn) => turn.runAttemptId === run.activeAttemptId && turn.status === "completed",24 "completed")
21 ))
22 );25 );
23}26}
apps/server/src/orchestration-v2/Orchestrator.ts+23-8
...@@ -43,6 +43,7 @@ import {...@@ -43,6 +43,7 @@ import {
43 type OrchestrationV2Subagent,43 type OrchestrationV2Subagent,
44 type OrchestrationV2ThreadProjection,44 type OrchestrationV2ThreadProjection,
45 type OrchestrationV2TurnItem,45 type OrchestrationV2TurnItem,
46 latestProviderTurnForAttempt,
46 orchestrationV2RunWorkStartedAt,47 orchestrationV2RunWorkStartedAt,
47 ProviderInstanceId,48 ProviderInstanceId,
48 type ProviderSessionId,49 type ProviderSessionId,
...@@ -370,6 +371,14 @@ export function isNativeMaintenanceCommand(message: {...@@ -370,6 +371,14 @@ export function isNativeMaintenanceCommand(message: {
370 );371 );
371}372}
372373
374/** A native `/goal` command. It changes the provider's goal, so it never steers a running turn. */
375function isGoalCommand(message: {
376 readonly text: string;
377 readonly attachments: ReadonlyArray<ChatAttachment>;
378}): boolean {
379 return message.attachments.length === 0 && /^\/goal(?:\s|$)/u.test(message.text.trim());
380}
381
373const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest));382const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest));
374383
375function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {384function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
...@@ -601,7 +610,7 @@ function providerTurnForRun(...@@ -601,7 +610,7 @@ function providerTurnForRun(
601 }610 }
602611
603 return (612 return (
604 projection.providerTurns.find((turn) => turn.runAttemptId === run.activeAttemptId) ??613 latestProviderTurnForAttempt(projection.providerTurns, run.activeAttemptId) ??
605 projection.providerTurns.find((turn) => {614 projection.providerTurns.find((turn) => {
606 const attempt = projection.attempts.find((candidate) => candidate.id === run.activeAttemptId);615 const attempt = projection.attempts.find((candidate) => candidate.id === run.activeAttemptId);
607 return attempt?.providerTurnId === turn.id;616 return attempt?.providerTurnId === turn.id;
...@@ -3669,6 +3678,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -3669,6 +3678,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
3669 : "Signing out must run as a separate turn. Queue it or wait for the active turn to finish.",3678 : "Signing out must run as a separate turn. Queue it or wait for the active turn to finish.",
3670 });3679 });
3671 }3680 }
3681 if (isGoalCommand(input)) {
3682 return yield* new OrchestratorDispatchError({
3683 commandId: input.command.commandId,
3684 commandType: input.command.type,
3685 cause:
3686 "Goal commands must run as a separate turn. Queue it or wait for the active turn to finish.",
3687 });
3688 }
3672 const targetMessage = input.projection.messages.find(3689 const targetMessage = input.projection.messages.find(
3673 (message) => message.id === targetRun.userMessageId,3690 (message) => message.id === targetRun.userMessageId,
3674 );3691 );
...@@ -4531,10 +4548,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -4531,10 +4548,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
4531 if (dispatchMode.type === "steer_active") {4548 if (dispatchMode.type === "steer_active") {
4532 const targetRunId = dispatchMode.targetRunId;4549 const targetRunId = dispatchMode.targetRunId;
4533 const target = projection.runs.find((run) => run.id === targetRunId);4550 const target = projection.runs.find((run) => run.id === targetRunId);
4534 const turn = projection.providerTurns.find(4551 const turn = latestProviderTurnForAttempt(
4535 (candidate) =>4552 projection.providerTurns.filter((candidate) => candidate.nodeId === target?.rootNodeId),
4536 candidate.runAttemptId === target?.activeAttemptId &&4553 target?.activeAttemptId,
4537 candidate.nodeId === target?.rootNodeId,
4538 );4554 );
4539 // The client may still show a running turn while its completion is being4555 // The client may still show a running turn while its completion is being
4540 // projected. Preserve the submission as a new turn when steering is too late.4556 // projected. Preserve the submission as a new turn when steering is too late.
...@@ -5442,9 +5458,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -5442,9 +5458,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
5442 const sourceProviderTurnId =5458 const sourceProviderTurnId =
5443 sourceProjection === null || sourceRun === null || sourceRun.activeAttemptId === null5459 sourceProjection === null || sourceRun === null || sourceRun.activeAttemptId === null
5444 ? undefined5460 ? undefined
5445 : (sourceProjection.providerTurns.find(5461 : (latestProviderTurnForAttempt(sourceProjection.providerTurns, sourceRun.activeAttemptId)
5446 (candidate) => candidate.runAttemptId === sourceRun.activeAttemptId,5462 ?.id ??
5447 )?.id ??
5448 sourceProjection.attempts.find(5463 sourceProjection.attempts.find(
5449 (candidate) => candidate.id === sourceRun.activeAttemptId,5464 (candidate) => candidate.id === sourceRun.activeAttemptId,
5450 )?.providerTurnId ??5465 )?.providerTurnId ??
apps/server/src/orchestration-v2/ProjectionStore.test.ts+79
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 CheckpointScopeId,8 CheckpointScopeId,
9 MessageId,9 MessageId,
10 type ModelSelection,10 type ModelSelection,
11 type OrchestrationV2ProviderThread,
11 NodeId,12 NodeId,
12 ProjectId,13 ProjectId,
13 ProviderDriverKind,14 ProviderDriverKind,
...@@ -1604,6 +1605,84 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => {...@@ -1604,6 +1605,84 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => {
1604 }),1605 }),
1605 );1606 );
16061607
1608 it.effect("shows the native goal of the active provider thread on the shell", () =>
1609 Effect.gen(function* () {
1610 const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
1611 const now = yield* DateTime.now;
1612 const threadId = ThreadId.make("thread:provider-goal");
1613 yield* projectionStore.apply({
1614 id: EventId.make("event:provider-goal:thread"),
1615 type: "thread.created",
1616 threadId,
1617 occurredAt: now,
1618 payload: {
1619 createdBy: "user",
1620 creationSource: "web",
1621 id: threadId,
1622 projectId: ProjectId.make("project:provider-goal"),
1623 title: "Provider goal",
1624 providerInstanceId,
1625 modelSelection,
1626 runtimeMode: "full-access",
1627 interactionMode: "default",
1628 branch: null,
1629 worktreePath: null,
1630 activeProviderThreadId: null,
1631 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
1632 forkedFrom: null,
1633 createdAt: now,
1634 updatedAt: now,
1635 archivedAt: null,
1636 settledOverride: null,
1637 settledAt: null,
1638 lastVisitedAt: null,
1639 deletedAt: null,
1640 },
1641 });
1642 const applyProviderThread = (
1643 suffix: string,
1644 goal: OrchestrationV2ProviderThread["goal"],
1645 seconds: number,
1646 ) =>
1647 projectionStore.apply({
1648 id: EventId.make(`event:provider-goal:${suffix}:${seconds}`),
1649 type: "provider-thread.updated",
1650 threadId,
1651 driver,
1652 occurredAt: DateTime.add(now, { seconds }),
1653 payload: {
1654 id: ProviderThreadId.make(`provider-thread:provider-goal:${suffix}`),
1655 driver,
1656 providerInstanceId,
1657 providerSessionId: null,
1658 appThreadId: threadId,
1659 ownerNodeId: null,
1660 nativeThreadRef: null,
1661 nativeConversationHeadRef: null,
1662 status: "idle",
1663 firstRunOrdinal: null,
1664 lastRunOrdinal: null,
1665 handoffIds: [],
1666 forkedFrom: null,
1667 goal,
1668 createdAt: now,
1669 updatedAt: DateTime.add(now, { seconds }),
1670 },
1671 });
1672 const shellGoal = Effect.map(
1673 projectionStore.getShellSnapshot(),
1674 (snapshot) => snapshot.threads.find((thread) => thread.id === threadId)?.goal,
1675 );
1676 const goal = { objective: "Ship it", status: "active" as const, tokensUsed: 10 };
1677
1678 yield* applyProviderThread("first", goal, 0);
1679 assert.deepEqual(yield* shellGoal, goal);
1680 // A handoff moves the conversation; the previous provider's goal stays behind.
1681 yield* applyProviderThread("second", null, 1);
1682 assert.isNull(yield* shellGoal);
1683 }),
1684 );
1685
1607 it.effect("does not treat visited or marked-unread state as thread activity", () =>1686 it.effect("does not treat visited or marked-unread state as thread activity", () =>
1608 Effect.gen(function* () {1687 Effect.gen(function* () {
1609 const projectionStore = yield* ProjectionStore.ProjectionStoreV2;1688 const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
apps/server/src/orchestration-v2/ProjectionStore.ts+18
...@@ -1410,6 +1410,10 @@ export function threadShellFromProjection(...@@ -1410,6 +1410,10 @@ export function threadShellFromProjection(
1410 threadId: projection.thread.id,1410 threadId: projection.thread.id,
1411 providerThreads: projection.providerThreads,1411 providerThreads: projection.providerThreads,
1412 }),1412 }),
1413 goal: activeProviderGoalForShell(
1414 projection.providerThreads,
1415 projection.thread.activeProviderThreadId,
1416 ),
1413 itemCount: activeLocalTurnItems(projection).length,1417 itemCount: activeLocalTurnItems(projection).length,
1414 visibleItemCount: projection.visibleTurnItems.length,1418 visibleItemCount: projection.visibleTurnItems.length,
1415 createdAt: projection.thread.createdAt,1419 createdAt: projection.thread.createdAt,
...@@ -1457,6 +1461,14 @@ function providerInstanceHistoryForShell(input: {...@@ -1457,6 +1461,14 @@ function providerInstanceHistoryForShell(input: {
1457 return history;1461 return history;
1458}1462}
14591463
1464/** The native goal lives on the provider thread that currently owns the conversation. */
1465function activeProviderGoalForShell(
1466 providerThreads: ReadonlyArray<OrchestrationV2ThreadProjection["providerThreads"][number]>,
1467 activeProviderThreadId: OrchestrationV2ThreadProjection["thread"]["activeProviderThreadId"],
1468): OrchestrationV2ThreadShell["goal"] {
1469 return providerThreads.find((thread) => thread.id === activeProviderThreadId)?.goal ?? null;
1470}
1471
1460function isInterruptibleRunForShell(run: OrchestrationV2ThreadProjection["runs"][number]): boolean {1472function isInterruptibleRunForShell(run: OrchestrationV2ThreadProjection["runs"][number]): boolean {
1461 return run.status === "preparing" || run.status === "starting" || run.status === "running";1473 return run.status === "preparing" || run.status === "starting" || run.status === "running";
1462}1474}
...@@ -1490,6 +1502,7 @@ type ShellThreadState = {...@@ -1490,6 +1502,7 @@ type ShellThreadState = {
1490 readonly hasActionableProposedPlan: boolean;1502 readonly hasActionableProposedPlan: boolean;
1491 readonly pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"];1503 readonly pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"];
1492 readonly providerInstanceHistory: OrchestrationV2ThreadShell["providerInstanceHistory"];1504 readonly providerInstanceHistory: OrchestrationV2ThreadShell["providerInstanceHistory"];
1505 readonly goal: OrchestrationV2ThreadShell["goal"];
1493 readonly itemCount: number;1506 readonly itemCount: number;
1494 readonly runlessItemCount: number;1507 readonly runlessItemCount: number;
1495 readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"];1508 readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"];
...@@ -1636,6 +1649,7 @@ function shellFromState(input: {...@@ -1636,6 +1649,7 @@ function shellFromState(input: {
1636 hasActionableProposedPlan: input.state.hasActionableProposedPlan,1649 hasActionableProposedPlan: input.state.hasActionableProposedPlan,
1637 pendingBackgroundTasks: input.state.pendingBackgroundTasks,1650 pendingBackgroundTasks: input.state.pendingBackgroundTasks,
1638 providerInstanceHistory: input.state.providerInstanceHistory,1651 providerInstanceHistory: input.state.providerInstanceHistory,
1652 goal: input.state.goal,
1639 itemCount: input.state.itemCount,1653 itemCount: input.state.itemCount,
1640 visibleItemCount: input.visibleItemCount,1654 visibleItemCount: input.visibleItemCount,
1641 createdAt: input.state.thread.createdAt,1655 createdAt: input.state.thread.createdAt,
...@@ -5364,6 +5378,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -5364,6 +5378,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
5364 threadId: thread.id,5378 threadId: thread.id,
5365 providerThreads: providerThreadsByThreadId.get(thread.id) ?? [],5379 providerThreads: providerThreadsByThreadId.get(thread.id) ?? [],
5366 }),5380 }),
5381 goal: activeProviderGoalForShell(
5382 providerThreadsByThreadId.get(thread.id) ?? [],
5383 thread.activeProviderThreadId,
5384 ),
5367 itemCount: row.item_count,5385 itemCount: row.item_count,
5368 runlessItemCount: row.runless_item_count,5386 runlessItemCount: row.runless_item_count,
5369 updatedAt: thread.updatedAt,5387 updatedAt: thread.updatedAt,
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+6-5
...@@ -2,6 +2,7 @@ import { modelSelectionsEqual } from "@t3tools/shared/model";...@@ -2,6 +2,7 @@ import { modelSelectionsEqual } from "@t3tools/shared/model";
2import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences";2import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences";
3import {3import {
4 CommandId,4 CommandId,
5 latestProviderTurnForAttempt,
5 type OrchestrationV2DomainEvent,6 type OrchestrationV2DomainEvent,
6 type OrchestrationV2ExecutionNode,7 type OrchestrationV2ExecutionNode,
7 type OrchestrationV2ProviderThread,8 type OrchestrationV2ProviderThread,
...@@ -617,11 +618,11 @@ export const layer: Layer.Layer<...@@ -617,11 +618,11 @@ export const layer: Layer.Layer<
617 const sourceAttempt = sourceProjection.attempts.find(618 const sourceAttempt = sourceProjection.attempts.find(
618 (candidate) => candidate.id === sourceRun?.activeAttemptId,619 (candidate) => candidate.id === sourceRun?.activeAttemptId,
619 );620 );
620 const sourceProviderTurn = sourceProjection.providerTurns.find(621 const sourceProviderTurn =
621 (candidate) =>622 latestProviderTurnForAttempt(sourceProjection.providerTurns, sourceAttempt?.id) ??
622 candidate.id === sourceAttempt?.providerTurnId ||623 sourceProjection.providerTurns.find(
623 candidate.runAttemptId === sourceAttempt?.id,624 (candidate) => candidate.id === sourceAttempt?.providerTurnId,
624 );625 );
625 if (sourceRun === undefined || sourceProviderThread === undefined) {626 if (sourceRun === undefined || sourceProviderThread === undefined) {
626 return yield* new ProviderTurnStartError({627 return yield* new ProviderTurnStartError({
627 runId,628 runId,
apps/server/src/provider/Layers/CodexProvider.ts+5
...@@ -691,6 +691,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -691,6 +691,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
691 skills: snapshot.skills,691 skills: snapshot.skills,
692 slashCommands: [692 slashCommands: [
693 COMPACT_SLASH_COMMAND,693 COMPACT_SLASH_COMMAND,
694 {
695 name: "goal",
696 description: "Set a goal Codex keeps working toward until it is done",
697 input: { hint: "Objective, or pause, resume, clear" },
698 },
694 {699 {
695 name: "feedback",700 name: "feedback",
696 description: "Send this thread and Codex logs to OpenAI",701 description: "Send this thread and Codex logs to OpenAI",
apps/server/src/provider/Layers/ProviderRegistry.test.ts+5
...@@ -446,6 +446,11 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test...@@ -446,6 +446,11 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
446 },446 },
447 ]);447 ]);
448 assert.deepStrictEqual(status.slashCommands.slice(1), [448 assert.deepStrictEqual(status.slashCommands.slice(1), [
449 {
450 name: "goal",
451 description: "Set a goal Codex keeps working toward until it is done",
452 input: { hint: "Objective, or pause, resume, clear" },
453 },
449 {454 {
450 name: "feedback",455 name: "feedback",
451 description: "Send this thread and Codex logs to OpenAI",456 description: "Send this thread and Codex logs to OpenAI",
apps/web/src/components/ChatView.tsx+133-70
...@@ -97,6 +97,7 @@ import {...@@ -97,6 +97,7 @@ import {
97 deriveLatestThreadRun,97 deriveLatestThreadRun,
98 deriveThreadRuntime,98 deriveThreadRuntime,
99 presentPendingBackgroundWork,99 presentPendingBackgroundWork,
100 presentProviderGoal,
100} from "@t3tools/client-runtime/state/thread-execution";101} from "@t3tools/client-runtime/state/thread-execution";
101import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";102import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
102import {103import {
...@@ -293,6 +294,7 @@ import {...@@ -293,6 +294,7 @@ import {
293 ChevronDownIcon,294 ChevronDownIcon,
294 DownloadIcon,295 DownloadIcon,
295 GitBranchIcon,296 GitBranchIcon,
297 TargetIcon,
296 WifiOffIcon,298 WifiOffIcon,
297} from "lucide-react";299} from "lucide-react";
298import { cn, randomUUID } from "~/lib/utils";300import { cn, randomUUID } from "~/lib/utils";
...@@ -7131,6 +7133,130 @@ export default function ChatView(props: ChatViewProps) {...@@ -7131,6 +7133,130 @@ export default function ChatView(props: ChatViewProps) {
7131 [environmentId, navigate],7133 [environmentId, navigate],
7132 );7134 );
71337135
7136 // Commands such as /compact and /goal clear run as their own turn. The draft
7137 // and its attachments stay local.
7138 const sendStandaloneCommand = useCallback(
7139 async (text: string, failureMessage: string) => {
7140 if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return;
7141 const context = composerRef.current?.getSendContext();
7142 if (!context?.providerAvailable) return;
7143
7144 const threadId = activeThread.id;
7145 const messageId = newMessageId();
7146 const createdAt = new Date().toISOString();
7147 sendInFlightRef.current = true;
7148 beginLocalDispatch();
7149 setThreadError(threadId, null);
7150 setOptimisticUserMessages((messages) => [
7151 ...messages,
7152 {
7153 id: messageId,
7154 role: "user",
7155 text,
7156 runId: null,
7157 createdAt,
7158 updatedAt: createdAt,
7159 streaming: false,
7160 },
7161 ]);
7162 scrollToEnd();
7163 try {
7164 const settingsResult = await persistThreadSettingsForNextTurn({
7165 threadId,
7166 createdAt,
7167 ...(localCheckoutBranchMismatch
7168 ? { branch: localCheckoutBranchMismatch.currentBranch }
7169 : {}),
7170 runtimeMode,
7171 interactionMode: context.interactionMode,
7172 });
7173 const result =
7174 settingsResult._tag === "Failure"
7175 ? settingsResult
7176 : await startThreadTurn({
7177 environmentId,
7178 input: {
7179 threadId,
7180 message: { messageId, role: "user", text, attachments: [] },
7181 modelSelection: context.selectedModelSelection,
7182 runtimeMode,
7183 interactionMode: context.interactionMode,
7184 createdAt,
7185 },
7186 });
7187 if (result._tag === "Failure") {
7188 setOptimisticUserMessages((messages) =>
7189 messages.filter((message) => message.id !== messageId),
7190 );
7191 resetLocalDispatch();
7192 if (!isAtomCommandInterrupted(result)) {
7193 const error = squashAtomCommandFailure(result);
7194 setThreadError(threadId, error instanceof Error ? error.message : failureMessage);
7195 }
7196 } else {
7197 clearUsageLimitsFor(routeThreadKey);
7198 }
7199 } finally {
7200 sendInFlightRef.current = false;
7201 }
7202 },
7203 [
7204 activeThread,
7205 beginLocalDispatch,
7206 clientSettingsHydrated,
7207 composerRef,
7208 environmentId,
7209 localCheckoutBranchMismatch,
7210 persistThreadSettingsForNextTurn,
7211 resetLocalDispatch,
7212 routeThreadKey,
7213 runtimeMode,
7214 scrollToEnd,
7215 sendInFlightRef,
7216 setThreadError,
7217 startThreadTurn,
7218 ],
7219 );
7220 // A native /goal keeps the agent working across turns. Stop pauses a Codex
7221 // goal; once the thread is idle the row offers the native follow-ups.
7222 const activeGoal = activeThreadShell?.goal ?? null;
7223 const goalBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7224 if (!activeThread || activeGoal === null) return null;
7225 const presentation = presentProviderGoal(activeGoal, isWorking);
7226 return {
7227 id: `goal:${activeThread.id}`,
7228 variant: "default",
7229 priority: "activity",
7230 icon: <TargetIcon />,
7231 // Usage stays in the title so a long objective cannot clip it.
7232 title:
7233 presentation.usage === null
7234 ? presentation.title
7235 : `${presentation.title} · ${presentation.usage}`,
7236 description: presentation.objective,
7237 actions: isWorking ? undefined : (
7238 <>
7239 {presentation.canResume ? (
7240 <Button
7241 size="xs"
7242 variant="ghost"
7243 onClick={() => void sendStandaloneCommand("/goal resume", "Failed to resume goal.")}
7244 >
7245 Resume
7246 </Button>
7247 ) : null}
7248 <Button
7249 size="xs"
7250 variant="ghost"
7251 onClick={() => void sendStandaloneCommand("/goal clear", "Failed to clear goal.")}
7252 >
7253 Clear
7254 </Button>
7255 </>
7256 ),
7257 };
7258 }, [activeGoal, activeThread, isWorking, sendStandaloneCommand]);
7259
7134 const backgroundWorkBannerItem = useMemo<ComposerBannerStackItem | null>(() => {7260 const backgroundWorkBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7135 const presentation = presentPendingBackgroundWork(activeBackgroundTasks);7261 const presentation = presentPendingBackgroundWork(activeBackgroundTasks);
7136 if (presentation === null || !activeThread) {7262 if (presentation === null || !activeThread) {
...@@ -7397,7 +7523,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -7397,7 +7523,9 @@ export default function ChatView(props: ChatViewProps) {
7397 : null;7523 : null;
7398 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {7524 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
7399 const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner];7525 const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner];
7400 const backgroundWorkItems = backgroundWorkBannerItem === null ? [] : [backgroundWorkBannerItem];7526 const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter(
7527 (item) => item !== null,
7528 );
7401 const resumeCompactionItems =7529 const resumeCompactionItems =
7402 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];7530 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
7403 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];7531 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
...@@ -7476,6 +7604,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7476,6 +7604,7 @@ export default function ChatView(props: ChatViewProps) {
7476 handleRestoreThreadBranch,7604 handleRestoreThreadBranch,
7477 isRestoringThreadBranch,7605 isRestoringThreadBranch,
7478 backgroundWorkBannerItem,7606 backgroundWorkBannerItem,
7607 goalBannerItem,
7479 localCheckoutBranchMismatch,7608 localCheckoutBranchMismatch,
7480 parkedThreadBannerItem,7609 parkedThreadBannerItem,
7481 projectCloneBannerItem,7610 projectCloneBannerItem,
...@@ -8193,75 +8322,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -8193,75 +8322,9 @@ export default function ChatView(props: ChatViewProps) {
8193 setThreadError,8322 setThreadError,
8194 ],8323 ],
8195 );8324 );
8196 const onCompactContext = async () => {8325 const onCompactContext = () => {
8197 if (compactDisabled || !activeThread || !clientSettingsHydrated || sendInFlightRef.current) {8326 if (compactDisabled) return;
8198 return;8327 void sendStandaloneCommand("/compact", "Failed to compact context.");
8199 }
8200 const context = composerRef.current?.getSendContext();
8201 if (!context?.providerAvailable) return;
8202
8203 // Compaction is a standalone command; the draft and its attachments stay local.
8204 const threadId = activeThread.id;
8205 const messageId = newMessageId();
8206 const createdAt = new Date().toISOString();
8207 sendInFlightRef.current = true;
8208 beginLocalDispatch();
8209 setThreadError(threadId, null);
8210 setOptimisticUserMessages((messages) => [
8211 ...messages,
8212 {
8213 id: messageId,
8214 role: "user",
8215 text: "/compact",
8216 runId: null,
8217 createdAt,
8218 updatedAt: createdAt,
8219 streaming: false,
8220 },
8221 ]);
8222 scrollToEnd();
8223 try {
8224 const settingsResult = await persistThreadSettingsForNextTurn({
8225 threadId,
8226 createdAt,
8227 ...(localCheckoutBranchMismatch
8228 ? { branch: localCheckoutBranchMismatch.currentBranch }
8229 : {}),
8230 runtimeMode,
8231 interactionMode: context.interactionMode,
8232 });
8233 const result =
8234 settingsResult._tag === "Failure"
8235 ? settingsResult
8236 : await startThreadTurn({
8237 environmentId,
8238 input: {
8239 threadId,
8240 message: { messageId, role: "user", text: "/compact", attachments: [] },
8241 modelSelection: context.selectedModelSelection,
8242 runtimeMode,
8243 interactionMode: context.interactionMode,
8244 createdAt,
8245 },
8246 });
8247 if (result._tag === "Failure") {
8248 setOptimisticUserMessages((messages) =>
8249 messages.filter((message) => message.id !== messageId),
8250 );
8251 resetLocalDispatch();
8252 if (!isAtomCommandInterrupted(result)) {
8253 const error = squashAtomCommandFailure(result);
8254 setThreadError(
8255 threadId,
8256 error instanceof Error ? error.message : "Failed to compact context.",
8257 );
8258 }
8259 } else {
8260 clearUsageLimitsFor(routeThreadKey);
8261 }
8262 } finally {
8263 sendInFlightRef.current = false;
8264 }
8265 };8328 };
82668329
8267 const onResume = async () => {8330 const onResume = async () => {
apps/web/src/components/Sidebar.tsx+2-1
...@@ -1258,7 +1258,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1258,7 +1258,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1258 const topStatus =1258 const topStatus =
1259 status === "working"1259 status === "working"
1260 ? {1260 ? {
1261 label: "Working",1261 // A native /goal keeps the agent going across turns until it is met.
1262 label: thread.goal?.status === "active" ? "Goal" : "Working",
1262 icon: "working" as const,1263 icon: "working" as const,
1263 // No shimmer: a label that animates forever is noise in a sidebar1264 // No shimmer: a label that animates forever is noise in a sidebar
1264 // full of them (and repaints every vsync on high-refresh displays).1265 // full of them (and repaints every vsync on high-refresh displays).
docs/internals/providers.md+8
...@@ -116,6 +116,14 @@ resumes a run, queues behind active work, or steers when the adapter supports it...@@ -116,6 +116,14 @@ resumes a run, queues behind active work, or steers when the adapter supports it
116retain the provider's live response path. Do not infer that a request has disappeared merely because116retain the provider's live response path. Do not infer that a request has disappeared merely because
117it is outside the recent history window.117it is outside the recent history window.
118118
119Native `/goal` state belongs to the provider and is mirrored on the provider thread. Codex starts
120the next goal turn on its own milliseconds after the last one completes, so the
121[adapter](../../apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts) keeps the run open
122and adds that turn to it. One run can therefore own several native turns. `/goal` commands that
123start no Codex turn settle on a provider turn without a native ref, which native rollback must
124not count. Claude's SDK mode emits no goal events; its adapter reads goal state from the
125synthetic command output and Stop hook feedback in the transcript.
126
119Capabilities must describe what the provider can actually do. Antigravity can capture workspace127Capabilities must describe what the provider can actually do. Antigravity can capture workspace
120checkpoints but cannot roll back its conversation. The [checkpoint boundary](./overview.md#turn-completion-and-checkpoints)128checkpoints but cannot roll back its conversation. The [checkpoint boundary](./overview.md#turn-completion-and-checkpoints)
121therefore rejects revert before touching files. Native permission and question option IDs must129therefore rejects revert before touching files. Native permission and question option IDs must
docs/user/composer.md+13
...@@ -193,6 +193,19 @@ Provider commands must start the message to run. T3 Code commands such as...@@ -193,6 +193,19 @@ Provider commands must start the message to run. T3 Code commands such as
193Send `/compact` in an existing conversation to reduce context usage when the193Send `/compact` in an existing conversation to reduce context usage when the
194provider supports it. Web and desktop also offer compaction from the context meter.194provider supports it. Web and desktop also offer compaction from the context meter.
195195
196## Goals
197
198With Codex and Claude, send `/goal` followed by what "done" means, for example
199`/goal all tests in packages/api pass`. The agent keeps working across turns
200until it judges the goal met. The thread shows **Goal** while it works, and a
201row above the composer shows the goal and its progress.
202
203- `/goal` alone shows the current goal. `/goal clear` removes it.
204- Codex also supports `/goal pause` and `/goal resume`. Stopping a Codex goal
205 pauses it.
206- Stopping Claude ends the current turn, but the goal stays set. Claude checks it
207 again at the end of your next message.
208
196## Context in your message209## Context in your message
197210
198Context you attach lands where your cursor is, as a chip inside your text: a terminal excerpt,211Context you attach lands where your cursor is, as a chip inside your text: a terminal excerpt,
packages/client-runtime/src/state/models.ts+4
...@@ -5,6 +5,7 @@ import type {...@@ -5,6 +5,7 @@ import type {
5 EnvironmentId,5 EnvironmentId,
6 MessageId,6 MessageId,
7 OrchestrationProjectShell,7 OrchestrationProjectShell,
8 OrchestrationV2ProviderGoal,
8 OrchestrationV2RunStatus,9 OrchestrationV2RunStatus,
9 OrchestrationV2ProviderFailureClass,10 OrchestrationV2ProviderFailureClass,
10 OrchestrationV2ThreadProjection,11 OrchestrationV2ThreadProjection,
...@@ -112,6 +113,8 @@ export interface EnvironmentThreadShell {...@@ -112,6 +113,8 @@ export interface EnvironmentThreadShell {
112 >;113 >;
113 /** Provider instances that have owned the root conversation, oldest first. */114 /** Provider instances that have owned the root conversation, oldest first. */
114 readonly providerInstanceHistory: ReadonlyArray<ProviderInstanceId>;115 readonly providerInstanceHistory: ReadonlyArray<ProviderInstanceId>;
116 /** Native `/goal` on the active provider thread. */
117 readonly goal: OrchestrationV2ProviderGoal | null;
115 readonly itemCount: number;118 readonly itemCount: number;
116 readonly visibleItemCount: number;119 readonly visibleItemCount: number;
117 readonly createdAt: string;120 readonly createdAt: string;
...@@ -254,6 +257,7 @@ export function presentThreadShell(...@@ -254,6 +257,7 @@ export function presentThreadShell(
254 hasActionableProposedPlan: thread.hasActionableProposedPlan,257 hasActionableProposedPlan: thread.hasActionableProposedPlan,
255 pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],258 pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],
256 providerInstanceHistory: thread.providerInstanceHistory ?? [],259 providerInstanceHistory: thread.providerInstanceHistory ?? [],
260 goal: thread.goal ?? null,
257 itemCount: thread.itemCount,261 itemCount: thread.itemCount,
258 visibleItemCount: thread.visibleItemCount,262 visibleItemCount: thread.visibleItemCount,
259 createdAt: iso(thread.createdAt),263 createdAt: iso(thread.createdAt),
packages/client-runtime/src/state/threadExecution.test.ts+41
...@@ -18,6 +18,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -18,6 +18,7 @@ import { describe, expect, it } from "vite-plus/test";
18import { v2Projection } from "./orchestrationV2TestFixtures.ts";18import { v2Projection } from "./orchestrationV2TestFixtures.ts";
19import {19import {
20 presentPendingBackgroundWork,20 presentPendingBackgroundWork,
21 presentProviderGoal,
21 deriveReportedModelSelection,22 deriveReportedModelSelection,
22 deriveLatestThreadRun,23 deriveLatestThreadRun,
23 deriveProviderSubagentStatus,24 deriveProviderSubagentStatus,
...@@ -677,3 +678,43 @@ describe("provider-reported model selection", () => {...@@ -677,3 +678,43 @@ describe("provider-reported model selection", () => {
677 expect(formatModelSelectionEffort(selected, models)).toBe("Unknown");678 expect(formatModelSelectionEffort(selected, models)).toBe("Unknown");
678 });679 });
679});680});
681
682describe("presentProviderGoal", () => {
683 it("summarizes Codex accounting and offers resume once the goal stops short", () => {
684 expect(
685 presentProviderGoal(
686 {
687 objective: "Ship the feature",
688 status: "paused",
689 tokensUsed: 12_400,
690 tokenBudget: 50_000,
691 timeUsedSeconds: 245,
692 },
693 false,
694 ),
695 ).toEqual({
696 title: "Goal paused",
697 objective: "Ship the feature",
698 usage: "12k / 50k tokens · 4m 5s",
699 canResume: true,
700 });
701 });
702
703 it("counts Claude evaluator checks and never offers resume", () => {
704 expect(
705 presentProviderGoal({ objective: "All tests pass", status: "active", checks: 1 }, true),
706 ).toEqual({
707 title: "Pursuing goal",
708 objective: "All tests pass",
709 usage: "1 check",
710 canResume: false,
711 });
712 expect(
713 presentProviderGoal({ objective: "All tests pass", status: "complete", checks: 0 }, false)
714 .usage,
715 ).toBeNull();
716 expect(
717 presentProviderGoal({ objective: "All tests pass", status: "active" }, false).title,
718 ).toBe("Goal set");
719 });
720});
packages/client-runtime/src/state/threadExecution.ts+56
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 type ModelSelection,10 type ModelSelection,
11 type OrchestrationV2NotificationSource,11 type OrchestrationV2NotificationSource,
12 type OrchestrationV2PendingBackgroundTask,12 type OrchestrationV2PendingBackgroundTask,
13 type OrchestrationV2ProviderGoal,
13 type ServerProviderModel,14 type ServerProviderModel,
14 type OrchestrationV2ExecutionNode,15 type OrchestrationV2ExecutionNode,
15 type OrchestrationV2ThreadProjection,16 type OrchestrationV2ThreadProjection,
...@@ -369,6 +370,61 @@ export function presentPendingBackgroundWork(...@@ -369,6 +370,61 @@ export function presentPendingBackgroundWork(
369 return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting };370 return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting };
370}371}
371372
373export interface ProviderGoalPresentation {
374 /** "Pursuing goal", "Goal paused", "Goal complete"... */
375 readonly title: string;
376 readonly objective: string;
377 /** "12k / 50k tokens · 4m" for Codex, "2 checks" for Claude; null when nothing is reported. */
378 readonly usage: string | null;
379 /** Only Codex stops a goal short of done, and only a stopped goal can resume. */
380 readonly canResume: boolean;
381}
382
383const PROVIDER_GOAL_TITLES: Record<OrchestrationV2ProviderGoal["status"], string> = {
384 active: "Pursuing goal",
385 paused: "Goal paused",
386 blocked: "Goal blocked",
387 usage_limited: "Goal hit a usage limit",
388 budget_limited: "Goal reached its token budget",
389 complete: "Goal complete",
390};
391
392function formatGoalTokens(tokens: number): string {
393 if (tokens < 1_000) return `${tokens}`;
394 if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k`;
395 return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
396}
397
398/**
399 * Status line for a native `/goal`, shared by the web composer row and mobile.
400 * An active goal on an idle thread (Claude after Stop) is set, not pursued.
401 */
402export function presentProviderGoal(
403 goal: OrchestrationV2ProviderGoal,
404 working: boolean,
405): ProviderGoalPresentation {
406 const usage: Array<string> = [];
407 if (goal.tokensUsed !== undefined && goal.tokensUsed > 0) {
408 usage.push(
409 goal.tokenBudget == null
410 ? `${formatGoalTokens(goal.tokensUsed)} tokens`
411 : `${formatGoalTokens(goal.tokensUsed)} / ${formatGoalTokens(goal.tokenBudget)} tokens`,
412 );
413 }
414 if (goal.timeUsedSeconds !== undefined && goal.timeUsedSeconds >= 60) {
415 usage.push(formatDuration(goal.timeUsedSeconds * 1_000));
416 }
417 if (goal.checks !== undefined && goal.checks > 0) {
418 usage.push(`${goal.checks} ${goal.checks === 1 ? "check" : "checks"}`);
419 }
420 return {
421 title: goal.status === "active" && !working ? "Goal set" : PROVIDER_GOAL_TITLES[goal.status],
422 objective: goal.objective,
423 usage: usage.length === 0 ? null : usage.join(" · "),
424 canResume: goal.status !== "active" && goal.status !== "complete",
425 };
426}
427
372/** The thread a notification row opens: that of the one subagent or delegated task it reports. */428/** The thread a notification row opens: that of the one subagent or delegated task it reports. */
373export function notificationChildThreadId(429export function notificationChildThreadId(
374 source: OrchestrationV2NotificationSource,430 source: OrchestrationV2NotificationSource,
packages/contracts/src/orchestrationV2.test.ts+15
...@@ -16,12 +16,14 @@ import {...@@ -16,12 +16,14 @@ import {
16 ProviderInstanceId,16 ProviderInstanceId,
17 ProviderReplayTranscript,17 ProviderReplayTranscript,
18 ProviderThreadId,18 ProviderThreadId,
19 RunAttemptId,
19 RunId,20 RunId,
20 ThreadId,21 ThreadId,
21 TrimmedNonEmptyString,22 TrimmedNonEmptyString,
22 TurnItemId,23 TurnItemId,
23} from "./index.ts";24} from "./index.ts";
24import {25import {
26 latestProviderTurnForAttempt,
25 OrchestrationV2Checkpoint,27 OrchestrationV2Checkpoint,
26 OrchestrationV2CheckpointScope,28 OrchestrationV2CheckpointScope,
27 OrchestrationV2Command,29 OrchestrationV2Command,
...@@ -1406,3 +1408,16 @@ describe("limit recovery choice updates", () => {...@@ -1406,3 +1408,16 @@ describe("limit recovery choice updates", () => {
1406 expect(decode({ ...identity, ...choice })).toEqual({ ...identity, ...choice });1408 expect(decode({ ...identity, ...choice })).toEqual({ ...identity, ...choice });
1407 });1409 });
1408});1410});
1411
1412describe("latestProviderTurnForAttempt", () => {
1413 it("returns the attempt's highest-ordinal turn, as a Codex goal run spans several", () => {
1414 const turns = [
1415 { id: "first", runAttemptId: RunAttemptId.make("goal-attempt"), ordinal: 3 },
1416 { id: "other", runAttemptId: RunAttemptId.make("other-attempt"), ordinal: 9 },
1417 { id: "last", runAttemptId: RunAttemptId.make("goal-attempt"), ordinal: 5 },
1418 { id: "subagent", runAttemptId: null, ordinal: 7 },
1419 ];
1420 expect(latestProviderTurnForAttempt(turns, RunAttemptId.make("goal-attempt"))?.id).toBe("last");
1421 expect(latestProviderTurnForAttempt(turns, null)).toBeUndefined();
1422 });
1423});
packages/contracts/src/orchestrationV2.ts+53
...@@ -839,6 +839,34 @@ export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({...@@ -839,6 +839,34 @@ export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({
839export type OrchestrationV2ProviderThreadNativeMetadata =839export type OrchestrationV2ProviderThreadNativeMetadata =
840 typeof OrchestrationV2ProviderThreadNativeMetadata.Type;840 typeof OrchestrationV2ProviderThreadNativeMetadata.Type;
841841
842export const OrchestrationV2ProviderGoalStatus = Schema.Literals([
843 "active",
844 "paused",
845 "blocked",
846 "usage_limited",
847 "budget_limited",
848 "complete",
849]);
850export type OrchestrationV2ProviderGoalStatus = typeof OrchestrationV2ProviderGoalStatus.Type;
851
852/**
853 * A provider-native goal set with `/goal` (Codex and Claude). The provider
854 * keeps working until it judges the objective met and owns this state; T3
855 * mirrors the latest native report. Usage fields are provider-specific.
856 */
857export const OrchestrationV2ProviderGoal = Schema.Struct({
858 objective: TrimmedNonEmptyString,
859 status: OrchestrationV2ProviderGoalStatus,
860 /** Codex accounting for the goal across its turns. */
861 tokensUsed: Schema.optional(NonNegativeInt),
862 tokenBudget: Schema.optional(Schema.NullOr(NonNegativeInt)),
863 timeUsedSeconds: Schema.optional(NonNegativeInt),
864 /** Claude: evaluator checks that found the goal unmet, and the latest reason. */
865 checks: Schema.optional(NonNegativeInt),
866 lastCheck: Schema.optional(Schema.String),
867});
868export type OrchestrationV2ProviderGoal = typeof OrchestrationV2ProviderGoal.Type;
869
842export const OrchestrationV2ProviderThread = Schema.Struct({870export const OrchestrationV2ProviderThread = Schema.Struct({
843 id: ProviderThreadId,871 id: ProviderThreadId,
844 driver: ProviderDriverKind,872 driver: ProviderDriverKind,
...@@ -869,6 +897,10 @@ export const OrchestrationV2ProviderThread = Schema.Struct({...@@ -869,6 +897,10 @@ export const OrchestrationV2ProviderThread = Schema.Struct({
869 nativeMetadata: Schema.optional(Schema.NullOr(OrchestrationV2ProviderThreadNativeMetadata)).pipe(897 nativeMetadata: Schema.optional(Schema.NullOr(OrchestrationV2ProviderThreadNativeMetadata)).pipe(
870 Schema.withDecodingDefault(Effect.succeed(null)),898 Schema.withDecodingDefault(Effect.succeed(null)),
871 ),899 ),
900 /** Native goal on this provider thread; rows written before goals decode to null. */
901 goal: Schema.optional(Schema.NullOr(OrchestrationV2ProviderGoal)).pipe(
902 Schema.withDecodingDefault(Effect.succeed(null)),
903 ),
872 createdAt: Schema.DateTimeUtc,904 createdAt: Schema.DateTimeUtc,
873 updatedAt: Schema.DateTimeUtc,905 updatedAt: Schema.DateTimeUtc,
874});906});
...@@ -970,6 +1002,25 @@ export const OrchestrationV2ProviderTurn = Schema.Struct({...@@ -970,6 +1002,25 @@ export const OrchestrationV2ProviderTurn = Schema.Struct({
970});1002});
971export type OrchestrationV2ProviderTurn = typeof OrchestrationV2ProviderTurn.Type;1003export type OrchestrationV2ProviderTurn = typeof OrchestrationV2ProviderTurn.Type;
9721004
1005/**
1006 * The provider turn a run attempt is on or ended with. A Codex goal keeps one
1007 * run open across several native turns, so an attempt's first turn is not
1008 * always its last.
1009 */
1010export function latestProviderTurnForAttempt<
1011 Turn extends Pick<OrchestrationV2ProviderTurn, "runAttemptId" | "ordinal">,
1012>(
1013 providerTurns: ReadonlyArray<Turn>,
1014 attemptId: RunAttemptId | null | undefined,
1015): Turn | undefined {
1016 let latest: Turn | undefined;
1017 for (const turn of providerTurns) {
1018 if (attemptId == null || turn.runAttemptId !== attemptId) continue;
1019 if (latest === undefined || turn.ordinal > latest.ordinal) latest = turn;
1020 }
1021 return latest;
1022}
1023
973export const OrchestrationV2RuntimeRequest = Schema.Struct({1024export const OrchestrationV2RuntimeRequest = Schema.Struct({
974 id: RuntimeRequestId,1025 id: RuntimeRequestId,
975 nodeId: NodeId,1026 nodeId: NodeId,
...@@ -1812,6 +1863,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({...@@ -1812,6 +1863,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({
1812 providerInstanceHistory: Schema.optional(Schema.Array(ProviderInstanceId)).pipe(1863 providerInstanceHistory: Schema.optional(Schema.Array(ProviderInstanceId)).pipe(
1813 Schema.withDecodingDefault(Effect.succeed([])),1864 Schema.withDecodingDefault(Effect.succeed([])),
1814 ),1865 ),
1866 /** Native goal on the active provider thread; omitted by servers without goals. */
1867 goal: Schema.optional(Schema.NullOr(OrchestrationV2ProviderGoal)),
1815 itemCount: NonNegativeInt,1868 itemCount: NonNegativeInt,
1816 visibleItemCount: NonNegativeInt,1869 visibleItemCount: NonNegativeInt,
1817 createdAt: Schema.DateTimeUtc,1870 createdAt: Schema.DateTimeUtc,