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";
9898import IconStarFilled from "@tabler/icons-react-native/IconStarFilled";
9999import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";
100100import IconSun from "@tabler/icons-react-native/IconSun";
101import IconTarget from "@tabler/icons-react-native/IconTarget";
101102import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
102103import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
103104import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease";
......@@ -216,6 +217,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
216217 "star.fill": IconStarFilled,
217218 "sun.max": IconSun,
218219 "stop.fill": IconPlayerStopFilled,
220 target: IconTarget,
219221 terminal: IconTerminal2,
220222 "text.alignleft": IconAlignLeft,
221223 "text.bubble": IconMessage,
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+12-1
......@@ -31,7 +31,10 @@ import {
3131 type CodexArtifactTemplate,
3232} from "@t3tools/client-runtime/codex-artifact-templates";
3333import 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";
3538import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
3639import {
3740 formatModelSelectionEffort,
......@@ -491,6 +494,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
491494 waiting: pendingBackgroundWork.waiting,
492495 };
493496 }
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 }
494505 return null;
495506 })();
496507 const showWorkingControl = floatingStatus !== null;
apps/mobile/src/features/threads/floating-working-control.tsx+15
......@@ -407,6 +407,21 @@ function FloatingStatusLabel(props: {
407407 </StatusLabelRow>
408408 );
409409 }
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 }
410425 if (props.status.kind === "preparing") {
411426 return (
412427 <StatusLabelRow
apps/mobile/src/features/threads/floating-working-status.ts+2
......@@ -17,6 +17,8 @@ export type FloatingWorkingStatus =
1717 readonly accessibilityLabel: string;
1818 readonly waiting: boolean;
1919 }
20 // A native /goal on an idle thread: paused, blocked, complete, or set.
21 | { readonly kind: "goal"; readonly label: string; readonly accessibilityLabel: string }
2022 // A task whose thread the server has not created yet: the worktree may
2123 // still be checking out, so there is no turn to time.
2224 | { 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: {
587587 // label as the web sidebar, sourced from the server-side visited watermark
588588 // so checking a thread on any device clears it everywhere.
589589 const isUnread = status === "ready" && threadHasUnseenCompletion(thread);
590 const workingLabel = STATUS_LABEL_BY_STATUS[status];
590591 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) ??
592596 (isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined);
593597 // The timestamp is precomputed on the list item (same stamps the settled
594598 // 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", () => {
81728172 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
81738173 ),
81748174 );
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 });
81758324});
81768325
81778326describe("ClaudeAdapterV2 query message stream", () => {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+127
......@@ -48,6 +48,7 @@ import {
4848 type OrchestrationV2ExecutionNode,
4949 type OrchestrationV2ProviderCapabilities,
5050 type OrchestrationV2ProviderFailure,
51 OrchestrationV2ProviderGoal,
5152 type OrchestrationV2PlanArtifact,
5253 type OrchestrationV2PlanStep,
5354 type OrchestrationV2PendingBackgroundTask,
......@@ -1015,6 +1016,64 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"
10151016 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
10161017}
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
10181077/** Compares a subagent result with its routed text regardless of block joins. */
10191078function normalizeClaudeResultText(text: string): string {
10201079 return text.replace(/\s+/g, " ").trim();
......@@ -3049,6 +3108,10 @@ export function makeClaudeAdapterV2(
30493108 const lastProviderThreadByNativeThread = yield* Ref.make(
30503109 new Map<string, OrchestrationV2ProviderThread>(),
30513110 );
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>();
30523115 // Subagent registry that survives turn settle: a background subagent
30533116 // (Agent with run_in_background) can complete after the root turn
30543117 // ended, and its task_notification must both count as wake evidence
......@@ -3452,6 +3515,9 @@ export function makeClaudeAdapterV2(
34523515 providerSessionId: session.id,
34533516 ...(input.status === undefined ? {} : { status: input.status }),
34543517 pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster),
3518 ...(goalsByNativeThread.has(input.nativeThreadId)
3519 ? { goal: goalsByNativeThread.get(input.nativeThreadId) ?? null }
3520 : {}),
34553521 updatedAt: now,
34563522 };
34573523 yield* rememberProviderThread(providerThread);
......@@ -3462,6 +3528,37 @@ export function makeClaudeAdapterV2(
34623528 });
34633529 });
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
34653562 const rememberOpaqueTasks = (tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>) =>
34663563 tasks.length === 0
34673564 ? Effect.void
......@@ -4975,10 +5072,36 @@ export function makeClaudeAdapterV2(
49755072 const clearConversationHead =
49765073 input.status === "completed" &&
49775074 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 }
49785098 const providerThread: OrchestrationV2ProviderThread = {
49795099 ...input.context.input.providerThread,
49805100 providerSessionId: session.id,
49815101 ...(clearConversationHead ? { nativeConversationHeadRef: null } : {}),
5102 ...(nativeThreadId !== null && goalsByNativeThread.has(nativeThreadId)
5103 ? { goal: goalsByNativeThread.get(nativeThreadId) ?? null }
5104 : {}),
49825105 firstRunOrdinal:
49835106 input.context.input.providerThread.firstRunOrdinal ??
49845107 input.context.input.runOrdinal,
......@@ -5552,6 +5675,7 @@ export function makeClaudeAdapterV2(
55525675 }
55535676 return;
55545677 }
5678 yield* trackClaudeGoal({ nativeThreadId: liveQuery.nativeThreadId, context, message });
55555679
55565680 // Subagent narration belongs to its child thread, never the parent log.
55575681 if (message.type === "stream_event" && !message.parent_tool_use_id) {
......@@ -7137,6 +7261,9 @@ export function makeClaudeAdapterV2(
71377261 return updated;
71387262 });
71397263 yield* rememberProviderThread(turnInput.providerThread);
7264 if (!goalsByNativeThread.has(nativeThreadId)) {
7265 goalsByNativeThread.set(nativeThreadId, turnInput.providerThread.goal ?? null);
7266 }
71407267 const context: ActiveClaudeTurnContext = {
71417268 input: turnInput,
71427269 nativeTurnId,
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+843-1
......@@ -1195,6 +1195,11 @@ describe("CodexAdapterV2 rollback mapping", () => {
11951195 const secondTurn = providerTurn("provider-turn-second", 2, "completed");
11961196 const runningTurn = providerTurn("provider-turn-running", 3, "running");
11971197 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
11991204 const numTurns = yield* CodexAdapterV2.resolveCodexRollbackTurnCount({
12001205 providerThread,
......@@ -1204,7 +1209,7 @@ describe("CodexAdapterV2 rollback mapping", () => {
12041209 appRunOrdinal: 1,
12051210 providerTurn: firstTurn,
12061211 },
1207 providerThreadTurns: [interruptedTurn, runningTurn, secondTurn, firstTurn],
1212 providerThreadTurns: [goalCommandTurn, interruptedTurn, runningTurn, secondTurn, firstTurn],
12081213 });
12091214
12101215 assert.equal(numTurns, 2);
......@@ -7328,4 +7333,841 @@ describe("CodexAdapterV2 post-settle continuation", () => {
73287333 assert.include(errorCauseChainText(error), "fork exploded");
73297334 }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
73307335 );
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 });
73318173});
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+694-104
......@@ -25,6 +25,7 @@ import {
2525 CodexSettings,
2626 defaultInstanceIdForDriver,
2727 isOrchestrationV2WorkActive,
28 OrchestrationV2ProviderGoal,
2829 ProviderDriverKind,
2930 type ProviderSetupError,
3031} from "@t3tools/contracts";
......@@ -148,6 +149,7 @@ import {
148149 type ProviderAdapterV2RollbackThreadInput,
149150 type ProviderAdapterV2RuntimePolicy,
150151 type ProviderAdapterV2SessionRuntime,
152 type ProviderAdapterV2InterruptInput,
151153 type ProviderAdapterV2SteerInput,
152154 type ProviderAdapterV2TurnInput,
153155} from "../ProviderAdapter.ts";
......@@ -847,11 +849,78 @@ function providerThreadFromCodexThread(input: {
847849 };
848850}
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.
850854const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean =>
851 turn.status === "completed" ||
852 turn.status === "interrupted" ||
853 turn.status === "failed" ||
854 turn.status === "cancelled";
855 turn.nativeTurnRef !== null &&
856 (turn.status === "completed" ||
857 turn.status === "interrupted" ||
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
856925const providerTurnsForThread = (
857926 providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>,
......@@ -1070,6 +1139,16 @@ function isPersistentCodexDynamicTool(item: CodexDynamicToolItem): boolean {
10701139
10711140type 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
10731152interface DeferredCodexRootTerminal {
10741153 readonly context: ActiveCodexTurnContext;
10751154 readonly event: CodexRootTerminalEvent;
......@@ -1704,10 +1783,53 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
17041783 // Native completion and the interrupt timeout share one finalization
17051784 // path. Serialize the race so only one can publish terminal events.
17061785 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
17081806 const emitProviderEvent = (event: ProviderAdapterV2Event) =>
17091807 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
17111833 // Call only for new model-output activity. A local item/completed can
17121834 // arrive while the upstream response stream is still retrying.
17131835 const completeProviderRetry = Effect.fn("CodexAdapterV2.completeProviderRetry")(function* (
......@@ -1814,6 +1936,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
18141936 completedAt: null,
18151937 },
18161938 });
1939 yield* rememberRootProviderThread(input.turnInput.providerThread);
18171940 return context;
18181941 });
18191942
......@@ -3910,6 +4033,63 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
39104033 });
39114034 return;
39124035 }
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 }
39134093 yield* rememberSubagentTurnStarted({
39144094 nativeThreadId: payload.threadId,
39154095 nativeTurnId: payload.turn.id,
......@@ -3918,6 +4098,22 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
39184098 }).pipe(Effect.orDie, turnTerminalizationPermit.withPermits(1)),
39194099 );
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
39214117 yield* client.handleServerNotification("error", (payload) =>
39224118 Effect.gen(function* () {
39234119 const context = yield* awaitActiveTurn(payload.turnId);
......@@ -5011,6 +5207,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
50115207 }
50125208 : event;
50135209 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 }
50145220 if (current.status === "failed" && current.failure.class === "usage_limit") {
50155221 const item = makeProviderFailureTurnItem({
50165222 idAllocator,
......@@ -5038,6 +5244,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
50385244 readonly failureMessage?: string;
50395245 readonly failureCode?: string | null;
50405246 readonly providerRetry?: ActiveCodexProviderRetry;
5247 readonly goalHoldTurn?: OrchestrationV2ProviderTurn;
50415248 }) {
50425249 const event = yield* makeRootTerminalEvent(input);
50435250 const hasActiveDescendants = Array.from((yield* Ref.get(activeTurns)).values()).some(
......@@ -5051,10 +5258,60 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
50515258 });
50525259 return;
50535260 }
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 }
50545281 yield* emitRootTerminal(input.context, event);
50555282 },
50565283 );
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
50585315 const flushReadyRootTerminals = Effect.fn("CodexAdapterV2.flushReadyRootTerminals")(
50595316 function* () {
50605317 const activeTurnContexts = Array.from((yield* Ref.get(activeTurns)).values());
......@@ -5141,35 +5398,49 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
51415398 for (const [key, part] of reasoningParts) {
51425399 if (part.turnId === input.nativeTurnId) reasoningParts.delete(key);
51435400 }
5144 yield* emitProviderEvent({
5145 type: "provider_turn.updated",
5146 driver: CODEX_PROVIDER,
5147 threadId: input.context.projectionThreadId,
5148 providerTurn: {
5149 id: input.context.providerTurnId,
5150 providerThreadId: input.context.providerThread.id,
5151 nodeId: input.context.providerNodeId,
5152 runAttemptId:
5153 input.context.subagent === null ? input.context.input.attemptId : null,
5154 nativeTurnRef: {
5155 driver: CODEX_PROVIDER,
5156 nativeId: input.nativeTurnId,
5157 strength: "strong",
5158 },
5159 ordinal: input.context.providerTurnOrdinal,
5160 status: input.status,
5161 startedAt: input.context.startedAt,
5162 completedAt: input.completedAt,
5163 turnTokenUsage: completeCodexTurnTokenUsage(
5164 usageStateForThread(
5165 input.context.providerThread.nativeThreadRef?.nativeId ??
5166 String(input.context.providerThread.id),
5167 ),
5168 input.nativeTurnId,
5169 input.status === "completed",
5170 ),
5401 const nativeThreadId = input.context.providerThread.nativeThreadRef?.nativeId;
5402 // Codex continues an active goal with another turn, so the run stays
5403 // open and this turn reads as running until that turn starts.
5404 const activation =
5405 nativeThreadId == null ? undefined : goalActivations.get(nativeThreadId);
5406 const holdsForGoal =
5407 input.context.subagent === null &&
5408 input.status === "completed" &&
5409 nativeThreadId != null &&
5410 (goalsByNativeThread.get(nativeThreadId)?.status === "active" ||
5411 (activation !== undefined && !activation.stopped));
5412 const completedTurn: OrchestrationV2ProviderTurn = {
5413 id: input.context.providerTurnId,
5414 providerThreadId: input.context.providerThread.id,
5415 nodeId: input.context.providerNodeId,
5416 runAttemptId:
5417 input.context.subagent === null ? input.context.input.attemptId : null,
5418 nativeTurnRef: {
5419 driver: CODEX_PROVIDER,
5420 nativeId: input.nativeTurnId,
5421 strength: "strong",
51715422 },
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 }
51735444 if (input.context.subagent !== null) {
51745445 yield* emitProviderEvent({
51755446 type: "node.updated",
......@@ -5251,6 +5522,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
52515522 yield* emitOrDeferRootTerminal({
52525523 ...input,
52535524 ...(providerRetry === undefined ? {} : { providerRetry }),
5525 ...(holdsForGoal ? { goalHoldTurn: completedTurn } : {}),
52545526 });
52555527 }
52565528 const waiter = (yield* Ref.get(turnWaiters)).get(input.nativeTurnId);
......@@ -5353,6 +5625,346 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
53535625 }),
53545626 );
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
53565968 const runtime: ProviderAdapterV2SessionRuntime = {
53575969 instanceId: adapterOptions.instanceId,
53585970 driver: CODEX_PROVIDER,
......@@ -5473,6 +6085,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
54736085 ),
54746086 Effect.flatMap(decodeCodexResumeMetadata),
54756087 );
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.
54766090 return {
54776091 ...threadInput.providerThread,
54786092 providerSessionId: input.providerSessionId,
......@@ -5552,61 +6166,21 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
55526166 }),
55536167 ),
55546168 ),
5555 startTurn: (turnInput) =>
5556 Effect.gen(function* () {
5557 const threadId = yield* getNativeThreadId(turnInput.providerThread);
5558
5559 const codexInput =
6169 startTurn: (turnInput) => {
6170 const goalCommand =
6171 turnInput.message.attachments.length === 0 &&
6172 turnInput.restartContinuationOfRunId === undefined
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,
55606179 turnInput.restartContinuationOfRunId === undefined
55616180 ? yield* toCodexInput(turnInput)
5562 : [];
5563 const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId);
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 });
6181 : [],
6182 );
56006183 }).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 ),
56106184 Effect.mapError(
56116185 (cause) =>
56126186 new ProviderAdapterTurnStartError({
......@@ -5617,12 +6191,20 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
56176191 cause,
56186192 }),
56196193 ),
5620 ),
6194 );
6195 },
56216196 steerTurn: (turnInput) =>
56226197 Effect.gen(function* () {
56236198 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);
56246206 const activeTurn = Array.from((yield* Ref.get(activeTurns)).values()).find(
5625 (candidate) => candidate.providerTurnId === turnInput.providerTurnId,
6207 (candidate) => candidate.providerTurnId === providerTurnId,
56266208 );
56276209 if (activeTurn === undefined) {
56286210 return yield* toProtocolError(
......@@ -5665,8 +6247,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
56656247 }),
56666248 ),
56676249 ),
5668 interruptTurn: (turnInput) =>
6250 interruptTurn: (requestedInput) =>
56696251 Effect.gen(function* () {
6252 const { turnInput, goalTurns } = yield* resolveGoalStopTarget(requestedInput);
56706253 const [activeTurnContexts, settledTurnContexts] =
56716254 yield* turnTerminalizationPermit.withPermits(1)(
56726255 Effect.gen(function* () {
......@@ -5685,28 +6268,36 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
56856268 (candidate) => candidate.providerThread.id === turnInput.providerThread.id,
56866269 )
56876270 : undefined);
5688 if (activeTurn === undefined) {
5689 // Stop on a settled turn this process retains nothing for
5690 // (released, restarted, or every command already reported).
5691 if (turnInput.requestRuntimeRestart === true) return;
5692 return yield* toProtocolError(
5693 `Provider turn ${turnInput.providerTurnId} is not active and cannot be interrupted.`,
6271 // A goal run spans several root turns, and work an earlier one
6272 // started can outlive it.
6273 const lineageRoots = Array.from(
6274 new Set([...(activeTurn === undefined ? [] : [activeTurn]), ...goalTurns]),
6275 );
6276 const inLineage = (context: ActiveCodexTurnContext) =>
6277 lineageRoots.some(
6278 (root) => context === root || isDescendantCodexTurn(context, root),
56946279 );
5695 }
56966280 const interruptTargetContexts = [
5697 activeTurn,
6281 ...(activeTurn === undefined ? [] : [activeTurn]),
56986282 ...activeTurnContexts.filter(
5699 (candidate) =>
5700 candidate !== activeTurn && isDescendantCodexTurn(candidate, activeTurn),
6283 (candidate) => candidate !== activeTurn && inLineage(candidate),
57016284 ),
57026285 ...settledTurnContexts.filter(
57036286 (candidate) =>
57046287 candidate !== activeTurn &&
57056288 ((turnInput.requestRuntimeRestart === true &&
57066289 candidate.providerThread.id === turnInput.providerThread.id) ||
5707 isDescendantCodexTurn(candidate, activeTurn)),
6290 inLineage(candidate)),
57086291 ),
57096292 ];
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 }
57106301 const interruptTargets: Array<{
57116302 readonly context: ActiveCodexTurnContext;
57126303 readonly completion: Deferred.Deferred<void, never>;
......@@ -5769,8 +6360,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
57696360 const activeLineage = yield* turnTerminalizationPermit.withPermits(1)(
57706361 Effect.gen(function* () {
57716362 const lineage = Array.from((yield* Ref.get(activeTurns)).values()).filter(
5772 (context) =>
5773 context === activeTurn || isDescendantCodexTurn(context, activeTurn),
6363 inLineage,
57746364 );
57756365 for (const context of lineage) {
57766366 if (trackedInterruptNativeTurnIds.has(context.nativeTurnId)) {
......@@ -5847,8 +6437,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
58476437 Effect.gen(function* () {
58486438 const lineage = Array.from((yield* Ref.get(activeTurns)).values()).filter(
58496439 (context) =>
5850 context !== activeTurn &&
5851 isDescendantCodexTurn(context, activeTurn) &&
6440 !lineageRoots.includes(context) &&
6441 inLineage(context) &&
58526442 !trackedInterruptNativeTurnIds.has(context.nativeTurnId),
58536443 );
58546444 for (const context of lineage) {
......@@ -6038,8 +6628,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
60386628 (cause) =>
60396629 new ProviderAdapterInterruptError({
60406630 driver: CODEX_PROVIDER,
6041 providerThreadId: turnInput.providerThread.id,
6042 providerTurnId: turnInput.providerTurnId,
6631 providerThreadId: requestedInput.providerThread.id,
6632 providerTurnId: requestedInput.providerTurnId,
60436633 cause,
60446634 }),
60456635 ),
apps/server/src/orchestration-v2/CheckpointRollbackService.ts+5-5
......@@ -1,6 +1,7 @@
11import {
22 CheckpointId,
33 CheckpointScopeId,
4 latestProviderTurnForAttempt,
45 type OrchestrationV2DomainEvent,
56 ProviderThreadId,
67 ThreadId,
......@@ -233,11 +234,10 @@ export const layer: Layer.Layer<
233234 const targetAttempt = projection.attempts.find(
234235 (attempt) => attempt.id === targetRun?.activeAttemptId,
235236 );
236 const targetTurn = projection.providerTurns.find(
237 (turn) =>
238 turn.id === targetAttempt?.providerTurnId ||
239 turn.runAttemptId === targetAttempt?.id,
240 );
237 // A goal run can span several native turns; roll back to its last.
238 const targetTurn =
239 latestProviderTurnForAttempt(projection.providerTurns, targetAttempt?.id) ??
240 projection.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId);
241241 if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) {
242242 return yield* new CheckpointRollbackExecutionError({
243243 reason: "provider-turn-unavailable",
apps/server/src/orchestration-v2/NotificationMailbox.ts+7-4
......@@ -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
37/**
48 * A persisted steer without an acceptance receipt can be retried as a continuation.
......@@ -16,8 +20,7 @@ export function isUndeliveredMailboxSteer(
1620 run !== undefined &&
1721 run.userMessageId !== message.id &&
1822 (["completed", "failed", "interrupted", "cancelled", "rolled_back"].includes(run.status) ||
19 projection.providerTurns.some(
20 (turn) => turn.runAttemptId === run.activeAttemptId && turn.status === "completed",
21 ))
23 latestProviderTurnForAttempt(projection.providerTurns, run.activeAttemptId)?.status ===
24 "completed")
2225 );
2326}
apps/server/src/orchestration-v2/Orchestrator.ts+23-8
......@@ -43,6 +43,7 @@ import {
4343 type OrchestrationV2Subagent,
4444 type OrchestrationV2ThreadProjection,
4545 type OrchestrationV2TurnItem,
46 latestProviderTurnForAttempt,
4647 orchestrationV2RunWorkStartedAt,
4748 ProviderInstanceId,
4849 type ProviderSessionId,
......@@ -370,6 +371,14 @@ export function isNativeMaintenanceCommand(message: {
370371 );
371372}
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
373382const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest));
374383
375384function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
......@@ -601,7 +610,7 @@ function providerTurnForRun(
601610 }
602611
603612 return (
604 projection.providerTurns.find((turn) => turn.runAttemptId === run.activeAttemptId) ??
613 latestProviderTurnForAttempt(projection.providerTurns, run.activeAttemptId) ??
605614 projection.providerTurns.find((turn) => {
606615 const attempt = projection.attempts.find((candidate) => candidate.id === run.activeAttemptId);
607616 return attempt?.providerTurnId === turn.id;
......@@ -3669,6 +3678,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
36693678 : "Signing out must run as a separate turn. Queue it or wait for the active turn to finish.",
36703679 });
36713680 }
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 }
36723689 const targetMessage = input.projection.messages.find(
36733690 (message) => message.id === targetRun.userMessageId,
36743691 );
......@@ -4531,10 +4548,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
45314548 if (dispatchMode.type === "steer_active") {
45324549 const targetRunId = dispatchMode.targetRunId;
45334550 const target = projection.runs.find((run) => run.id === targetRunId);
4534 const turn = projection.providerTurns.find(
4535 (candidate) =>
4536 candidate.runAttemptId === target?.activeAttemptId &&
4537 candidate.nodeId === target?.rootNodeId,
4551 const turn = latestProviderTurnForAttempt(
4552 projection.providerTurns.filter((candidate) => candidate.nodeId === target?.rootNodeId),
4553 target?.activeAttemptId,
45384554 );
45394555 // The client may still show a running turn while its completion is being
45404556 // 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
54425458 const sourceProviderTurnId =
54435459 sourceProjection === null || sourceRun === null || sourceRun.activeAttemptId === null
54445460 ? undefined
5445 : (sourceProjection.providerTurns.find(
5446 (candidate) => candidate.runAttemptId === sourceRun.activeAttemptId,
5447 )?.id ??
5461 : (latestProviderTurnForAttempt(sourceProjection.providerTurns, sourceRun.activeAttemptId)
5462 ?.id ??
54485463 sourceProjection.attempts.find(
54495464 (candidate) => candidate.id === sourceRun.activeAttemptId,
54505465 )?.providerTurnId ??
apps/server/src/orchestration-v2/ProjectionStore.test.ts+79
......@@ -8,6 +8,7 @@ import {
88 CheckpointScopeId,
99 MessageId,
1010 type ModelSelection,
11 type OrchestrationV2ProviderThread,
1112 NodeId,
1213 ProjectId,
1314 ProviderDriverKind,
......@@ -1604,6 +1605,84 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => {
16041605 }),
16051606 );
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
16071686 it.effect("does not treat visited or marked-unread state as thread activity", () =>
16081687 Effect.gen(function* () {
16091688 const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
apps/server/src/orchestration-v2/ProjectionStore.ts+18
......@@ -1410,6 +1410,10 @@ export function threadShellFromProjection(
14101410 threadId: projection.thread.id,
14111411 providerThreads: projection.providerThreads,
14121412 }),
1413 goal: activeProviderGoalForShell(
1414 projection.providerThreads,
1415 projection.thread.activeProviderThreadId,
1416 ),
14131417 itemCount: activeLocalTurnItems(projection).length,
14141418 visibleItemCount: projection.visibleTurnItems.length,
14151419 createdAt: projection.thread.createdAt,
......@@ -1457,6 +1461,14 @@ function providerInstanceHistoryForShell(input: {
14571461 return history;
14581462}
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
14601472function isInterruptibleRunForShell(run: OrchestrationV2ThreadProjection["runs"][number]): boolean {
14611473 return run.status === "preparing" || run.status === "starting" || run.status === "running";
14621474}
......@@ -1490,6 +1502,7 @@ type ShellThreadState = {
14901502 readonly hasActionableProposedPlan: boolean;
14911503 readonly pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"];
14921504 readonly providerInstanceHistory: OrchestrationV2ThreadShell["providerInstanceHistory"];
1505 readonly goal: OrchestrationV2ThreadShell["goal"];
14931506 readonly itemCount: number;
14941507 readonly runlessItemCount: number;
14951508 readonly updatedAt: OrchestrationV2ThreadProjection["updatedAt"];
......@@ -1636,6 +1649,7 @@ function shellFromState(input: {
16361649 hasActionableProposedPlan: input.state.hasActionableProposedPlan,
16371650 pendingBackgroundTasks: input.state.pendingBackgroundTasks,
16381651 providerInstanceHistory: input.state.providerInstanceHistory,
1652 goal: input.state.goal,
16391653 itemCount: input.state.itemCount,
16401654 visibleItemCount: input.visibleItemCount,
16411655 createdAt: input.state.thread.createdAt,
......@@ -5364,6 +5378,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
53645378 threadId: thread.id,
53655379 providerThreads: providerThreadsByThreadId.get(thread.id) ?? [],
53665380 }),
5381 goal: activeProviderGoalForShell(
5382 providerThreadsByThreadId.get(thread.id) ?? [],
5383 thread.activeProviderThreadId,
5384 ),
53675385 itemCount: row.item_count,
53685386 runlessItemCount: row.runless_item_count,
53695387 updatedAt: thread.updatedAt,
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+6-5
......@@ -2,6 +2,7 @@ import { modelSelectionsEqual } from "@t3tools/shared/model";
22import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences";
33import {
44 CommandId,
5 latestProviderTurnForAttempt,
56 type OrchestrationV2DomainEvent,
67 type OrchestrationV2ExecutionNode,
78 type OrchestrationV2ProviderThread,
......@@ -617,11 +618,11 @@ export const layer: Layer.Layer<
617618 const sourceAttempt = sourceProjection.attempts.find(
618619 (candidate) => candidate.id === sourceRun?.activeAttemptId,
619620 );
620 const sourceProviderTurn = sourceProjection.providerTurns.find(
621 (candidate) =>
622 candidate.id === sourceAttempt?.providerTurnId ||
623 candidate.runAttemptId === sourceAttempt?.id,
624 );
621 const sourceProviderTurn =
622 latestProviderTurnForAttempt(sourceProjection.providerTurns, sourceAttempt?.id) ??
623 sourceProjection.providerTurns.find(
624 (candidate) => candidate.id === sourceAttempt?.providerTurnId,
625 );
625626 if (sourceRun === undefined || sourceProviderThread === undefined) {
626627 return yield* new ProviderTurnStartError({
627628 runId,
apps/server/src/provider/Layers/CodexProvider.ts+5
......@@ -691,6 +691,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
691691 skills: snapshot.skills,
692692 slashCommands: [
693693 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 },
694699 {
695700 name: "feedback",
696701 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
446446 },
447447 ]);
448448 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 },
449454 {
450455 name: "feedback",
451456 description: "Send this thread and Codex logs to OpenAI",
apps/web/src/components/ChatView.tsx+133-70
......@@ -97,6 +97,7 @@ import {
9797 deriveLatestThreadRun,
9898 deriveThreadRuntime,
9999 presentPendingBackgroundWork,
100 presentProviderGoal,
100101} from "@t3tools/client-runtime/state/thread-execution";
101102import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
102103import {
......@@ -293,6 +294,7 @@ import {
293294 ChevronDownIcon,
294295 DownloadIcon,
295296 GitBranchIcon,
297 TargetIcon,
296298 WifiOffIcon,
297299} from "lucide-react";
298300import { cn, randomUUID } from "~/lib/utils";
......@@ -7131,6 +7133,130 @@ export default function ChatView(props: ChatViewProps) {
71317133 [environmentId, navigate],
71327134 );
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
71347260 const backgroundWorkBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
71357261 const presentation = presentPendingBackgroundWork(activeBackgroundTasks);
71367262 if (presentation === null || !activeThread) {
......@@ -7397,7 +7523,9 @@ export default function ChatView(props: ChatViewProps) {
73977523 : null;
73987524 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
73997525 const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner];
7400 const backgroundWorkItems = backgroundWorkBannerItem === null ? [] : [backgroundWorkBannerItem];
7526 const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter(
7527 (item) => item !== null,
7528 );
74017529 const resumeCompactionItems =
74027530 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
74037531 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
......@@ -7476,6 +7604,7 @@ export default function ChatView(props: ChatViewProps) {
74767604 handleRestoreThreadBranch,
74777605 isRestoringThreadBranch,
74787606 backgroundWorkBannerItem,
7607 goalBannerItem,
74797608 localCheckoutBranchMismatch,
74807609 parkedThreadBannerItem,
74817610 projectCloneBannerItem,
......@@ -8193,75 +8322,9 @@ export default function ChatView(props: ChatViewProps) {
81938322 setThreadError,
81948323 ],
81958324 );
8196 const onCompactContext = async () => {
8197 if (compactDisabled || !activeThread || !clientSettingsHydrated || sendInFlightRef.current) {
8198 return;
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 }
8325 const onCompactContext = () => {
8326 if (compactDisabled) return;
8327 void sendStandaloneCommand("/compact", "Failed to compact context.");
82658328 };
82668329
82678330 const onResume = async () => {
apps/web/src/components/Sidebar.tsx+2-1
......@@ -1258,7 +1258,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12581258 const topStatus =
12591259 status === "working"
12601260 ? {
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",
12621263 icon: "working" as const,
12631264 // No shimmer: a label that animates forever is noise in a sidebar
12641265 // 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
116116retain the provider's live response path. Do not infer that a request has disappeared merely because
117117it 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
119127Capabilities must describe what the provider can actually do. Antigravity can capture workspace
120128checkpoints but cannot roll back its conversation. The [checkpoint boundary](./overview.md#turn-completion-and-checkpoints)
121129therefore 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
193193Send `/compact` in an existing conversation to reduce context usage when the
194194provider 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
196209## Context in your message
197210
198211Context 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 {
55 EnvironmentId,
66 MessageId,
77 OrchestrationProjectShell,
8 OrchestrationV2ProviderGoal,
89 OrchestrationV2RunStatus,
910 OrchestrationV2ProviderFailureClass,
1011 OrchestrationV2ThreadProjection,
......@@ -112,6 +113,8 @@ export interface EnvironmentThreadShell {
112113 >;
113114 /** Provider instances that have owned the root conversation, oldest first. */
114115 readonly providerInstanceHistory: ReadonlyArray<ProviderInstanceId>;
116 /** Native `/goal` on the active provider thread. */
117 readonly goal: OrchestrationV2ProviderGoal | null;
115118 readonly itemCount: number;
116119 readonly visibleItemCount: number;
117120 readonly createdAt: string;
......@@ -254,6 +257,7 @@ export function presentThreadShell(
254257 hasActionableProposedPlan: thread.hasActionableProposedPlan,
255258 pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],
256259 providerInstanceHistory: thread.providerInstanceHistory ?? [],
260 goal: thread.goal ?? null,
257261 itemCount: thread.itemCount,
258262 visibleItemCount: thread.visibleItemCount,
259263 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";
1818import { v2Projection } from "./orchestrationV2TestFixtures.ts";
1919import {
2020 presentPendingBackgroundWork,
21 presentProviderGoal,
2122 deriveReportedModelSelection,
2223 deriveLatestThreadRun,
2324 deriveProviderSubagentStatus,
......@@ -677,3 +678,43 @@ describe("provider-reported model selection", () => {
677678 expect(formatModelSelectionEffort(selected, models)).toBe("Unknown");
678679 });
679680});
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 {
1010 type ModelSelection,
1111 type OrchestrationV2NotificationSource,
1212 type OrchestrationV2PendingBackgroundTask,
13 type OrchestrationV2ProviderGoal,
1314 type ServerProviderModel,
1415 type OrchestrationV2ExecutionNode,
1516 type OrchestrationV2ThreadProjection,
......@@ -369,6 +370,61 @@ export function presentPendingBackgroundWork(
369370 return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting };
370371}
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
372428/** The thread a notification row opens: that of the one subagent or delegated task it reports. */
373429export function notificationChildThreadId(
374430 source: OrchestrationV2NotificationSource,
packages/contracts/src/orchestrationV2.test.ts+15
......@@ -16,12 +16,14 @@ import {
1616 ProviderInstanceId,
1717 ProviderReplayTranscript,
1818 ProviderThreadId,
19 RunAttemptId,
1920 RunId,
2021 ThreadId,
2122 TrimmedNonEmptyString,
2223 TurnItemId,
2324} from "./index.ts";
2425import {
26 latestProviderTurnForAttempt,
2527 OrchestrationV2Checkpoint,
2628 OrchestrationV2CheckpointScope,
2729 OrchestrationV2Command,
......@@ -1406,3 +1408,16 @@ describe("limit recovery choice updates", () => {
14061408 expect(decode({ ...identity, ...choice })).toEqual({ ...identity, ...choice });
14071409 });
14081410});
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({
839839export type OrchestrationV2ProviderThreadNativeMetadata =
840840 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
842870export const OrchestrationV2ProviderThread = Schema.Struct({
843871 id: ProviderThreadId,
844872 driver: ProviderDriverKind,
......@@ -869,6 +897,10 @@ export const OrchestrationV2ProviderThread = Schema.Struct({
869897 nativeMetadata: Schema.optional(Schema.NullOr(OrchestrationV2ProviderThreadNativeMetadata)).pipe(
870898 Schema.withDecodingDefault(Effect.succeed(null)),
871899 ),
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 ),
872904 createdAt: Schema.DateTimeUtc,
873905 updatedAt: Schema.DateTimeUtc,
874906});
......@@ -970,6 +1002,25 @@ export const OrchestrationV2ProviderTurn = Schema.Struct({
9701002});
9711003export 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
9731024export const OrchestrationV2RuntimeRequest = Schema.Struct({
9741025 id: RuntimeRequestId,
9751026 nodeId: NodeId,
......@@ -1812,6 +1863,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({
18121863 providerInstanceHistory: Schema.optional(Schema.Array(ProviderInstanceId)).pipe(
18131864 Schema.withDecodingDefault(Effect.succeed([])),
18141865 ),
1866 /** Native goal on the active provider thread; omitted by servers without goals. */
1867 goal: Schema.optional(Schema.NullOr(OrchestrationV2ProviderGoal)),
18151868 itemCount: NonNegativeInt,
18161869 visibleItemCount: NonNegativeInt,
18171870 createdAt: Schema.DateTimeUtc,