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