From 6b9510c58f9deec0fcb45f3612307cbeb2d264fb Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:07:40 -0500 Subject: [PATCH] feat: add provider-neutral conversation lifecycle (#856) (#958) --- AGENTS.md | 17 +- CHANGELOG.md | 10 + README.md | 4 + .../agents-runtime-capabilities.test.ts | 48 + cli/src/commands/agents.ts | 190 ++ docs/AGENT-PROVIDERS.md | 87 +- docs/API-REFERENCE.md | 35 +- docs/CLI-GUIDE.md | 15 + docs/FEATURES.md | 14 +- docs/security/permission-coverage.json | 72 + mcp/src/__tests__/agent-tools.test.ts | 50 + mcp/src/tools/agents.ts | 130 ++ .../__tests__/claude-code-provider.test.ts | 42 +- .../codex-app-server-provider.test.ts | 91 +- .../conversation-lifecycle-service.test.ts | 314 +++ .../routes/agents-local-capability.test.ts | 123 + .../__tests__/shared-api-permissions.test.ts | 23 + .../v2/ThreadArchiveResponse.json | 5 + .../v2/ThreadCompactStartResponse.json | 5 + .../v2/ThreadForkResponse.json | 2030 ++++++++++++++++ .../v2/ThreadResumeResponse.json | 2070 +++++++++++++++++ .../v2/TurnSteerResponse.json | 11 + server/src/routes/agents.ts | 208 +- server/src/services/claude-code-adapter.ts | 17 + server/src/services/clawdbot-agent-service.ts | 802 ++++++- .../src/services/codex-app-server-adapter.ts | 94 + .../conversation-lifecycle-service.ts | 284 +++ .../provider-runtime-adapter-registry.ts | 47 +- .../provider-runtime-control-service.ts | 5 + .../src/types/conversation-lifecycle.types.ts | 69 + shared/src/types/index.ts | 1 + shared/src/types/provider-runtime.types.ts | 10 +- shared/src/types/run-event.types.ts | 9 + shared/src/types/task.types.ts | 1 + shared/src/utils/api-permissions.ts | 10 + ...il-agent-template-metrics-mantine.test.tsx | 16 +- web/src/components/task/AgentPanel.tsx | 35 +- web/src/hooks/useAgent.ts | 65 + web/src/lib/api/agent.ts | 118 +- 39 files changed, 7007 insertions(+), 170 deletions(-) create mode 100644 server/src/__tests__/conversation-lifecycle-service.test.ts create mode 100644 server/src/contracts/codex-app-server-v0.145.0/v2/ThreadArchiveResponse.json create mode 100644 server/src/contracts/codex-app-server-v0.145.0/v2/ThreadCompactStartResponse.json create mode 100644 server/src/contracts/codex-app-server-v0.145.0/v2/ThreadForkResponse.json create mode 100644 server/src/contracts/codex-app-server-v0.145.0/v2/ThreadResumeResponse.json create mode 100644 server/src/contracts/codex-app-server-v0.145.0/v2/TurnSteerResponse.json create mode 100644 server/src/services/conversation-lifecycle-service.ts create mode 100644 shared/src/types/conversation-lifecycle.types.ts diff --git a/AGENTS.md b/AGENTS.md index 9cd1d54a..96575767 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,8 +173,12 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise, - App-server launch arguments are system-owned. Inherited MCP servers, hooks, plugins, apps, browser/computer tools, and remote control remain disabled. - App-server consumes only the checked-in v0.145.0 schemas and exposes - `initialize`, `thread/start`, `turn/start`, and `turn/interrupt`. - `thread/shellCommand` is never reachable. + `initialize`, thread start/resume/fork/compact/archive, and turn + start/steer/interrupt. `thread/shellCommand` is never reachable. +- `conversation-lifecycle/v1` persists opaque thread, turn, item, parent, and + fork identities. Resume and fork validate the source launch manifest, + provider/model/policy, base revision, and worktree compatibility before a new + attempt is created. - App-server command, file, permission, tool-question, and elicitation requests use `run-approval/v1`. Decisions must preserve the persisted revision and exact action hash; interruption and cancellation invalidate pending requests. @@ -190,9 +194,12 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise, - The terminal `result` record is authoritative. Veritas drains stdout after process close, persists `session_id`, and maps partial, hook, tool, subagent, usage, cost, and result records into `run-event/v1`. -- Resume, fork, and MCP injection remain fail-closed. The shared approval broker - is available, but Claude stays on static `dontAsk` permissions until its - adapter exposes a pinned interactive request/response contract. +- Resume uses the exact persisted session through system-owned `--resume`. + Native history fork adds `--fork-session`; caller-supplied lifecycle flags + remain prohibited. MCP injection remains fail-closed. +- The shared approval broker is available, but Claude stays on static + `dontAsk` permissions until its adapter exposes a pinned interactive + request/response contract. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b3b151..9096045b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `conversation-lifecycle/v1` with durable provider conversation, turn, + item, parent, and fork identities; launch-manifest and worktree compatibility + checks; context-window posture; causal lifecycle events; attributed REST, + CLI, MCP, and web controls; and explicit unsupported delivery results. + Codex CLI and SDK can resume exact persisted sessions, Claude Code can resume + or fork with system-owned native flags, and Codex app-server can resume, + follow up, steer, fork, compact, archive, interrupt, and close through + schema-validated JSON-RPC methods. Generic process stdin is no longer treated + as a successful provider follow-up path, and capability evidence advances to + probe revision 8 (#856). - Added `run-supervisor/v1`, a durable provider-run ownership record with exact provider, task-envelope, launch-manifest, and worktree bindings; persisted process groups or remote-session handles; budget and event cursors; expiring diff --git a/README.md b/README.md index ba1b9e15..03d0f5b0 100644 --- a/README.md +++ b/README.md @@ -623,6 +623,10 @@ vk profiles list # List reusable agent profile packages vk profiles validate ./agent.yml # Validate a package before import vk profiles import ./agent.yml # Import or replace a package vk start --profile # Launch a task with a profile package +vk agent:resume --source-attempt -m "Continue the work" +vk agent:fork --source-attempt --fork-turn -m "Try another path" +vk agent:steer --attempt -m "Use the smaller fix" +vk agent:compact --attempt ``` ### Utilities diff --git a/cli/src/__tests__/agents-runtime-capabilities.test.ts b/cli/src/__tests__/agents-runtime-capabilities.test.ts index 860f9bcf..47505ff9 100644 --- a/cli/src/__tests__/agents-runtime-capabilities.test.ts +++ b/cli/src/__tests__/agents-runtime-capabilities.test.ts @@ -119,6 +119,54 @@ describe('vk agent runtime capability controls', () => { }); }); + it('starts a native history fork from an explicit source attempt and turn', async () => { + const program = new Command(); + program.exitOverride(); + registerAgentCommands(program); + + await program.parseAsync( + [ + 'agent:fork', + 'task_1', + '--source-attempt', + 'attempt_parent', + '--message', + 'Explore the alternate fix', + '--fork-turn', + 'turn_7', + '--require-capability', + 'tool.mcp', + '--json', + ], + { from: 'user' } + ); + + expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/conversation/fork', { + method: 'POST', + body: JSON.stringify({ + sourceAttemptId: 'attempt_parent', + message: 'Explore the alternate fix', + forkTurnId: 'turn_7', + requiredRuntimeCapabilities: ['tool.mcp'], + }), + }); + }); + + it('binds compact controls to the exact active attempt', async () => { + const program = new Command(); + program.exitOverride(); + registerAgentCommands(program); + + await program.parseAsync(['agent:compact', 'task_1', '--attempt', 'attempt_1', '--json'], { + from: 'user', + }); + + expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/conversation/compact', { + method: 'POST', + body: JSON.stringify({ attemptId: 'attempt_1' }), + }); + }); + it('forwards attempt and manifest provenance when completing a run', async () => { const program = new Command(); program.exitOverride(); diff --git a/cli/src/commands/agents.ts b/cli/src/commands/agents.ts index d32bf105..fd64b76c 100644 --- a/cli/src/commands/agents.ts +++ b/cli/src/commands/agents.ts @@ -9,14 +9,140 @@ import type { AgentProfilePackageFormat, AgentProfilePackageSummary, AgentProfileValidationResult, + ConversationLifecycleRecord, + ConversationLifecycleResult, RunLaunchManifestPreview, } from '@veritas-kanban/shared'; +type ConversationTurnAction = 'resume' | 'follow-up' | 'fork'; +type ConversationControlAction = 'interrupt' | 'compact' | 'archive' | 'close'; + +interface ConversationTurnOptions { + sourceAttempt: string; + message: string; + forkTurn?: string; + profile?: string; + requireCapability?: string[]; + commitPolicy?: string; + json?: boolean; +} + +interface ConversationControlOptions { + attempt: string; + json?: boolean; +} + function inferProfileFormat(filePath: string): AgentProfilePackageFormat { const extension = path.extname(filePath).toLowerCase(); return extension === '.json' ? 'json' : 'yaml'; } +async function resolveTaskId(id: string): Promise { + const task = await findTask(id); + if (!task) throw new Error(`Task not found: ${id}`); + return task.id; +} + +function printConversationResult( + action: string, + result: { + attemptId: string; + delivered?: boolean; + note?: string; + conversation?: ConversationLifecycleRecord; + }, + json?: boolean +): void { + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(chalk.green(`✓ Conversation ${action}`)); + console.log(chalk.dim(`Attempt ID: ${result.attemptId}`)); + if (result.conversation?.conversationId) { + console.log(chalk.dim(`Conversation ID: ${result.conversation.conversationId}`)); + } + if (result.note) console.log(chalk.dim(result.note)); +} + +function registerConversationTurnCommand( + program: Command, + action: ConversationTurnAction, + description: string +): void { + const command = program + .command(`agent:${action} `) + .description(description) + .requiredOption('--source-attempt ', 'Terminal attempt with durable conversation') + .requiredOption('-m, --message ', 'Prompt for the new turn') + .option('-p, --profile ', 'Agent profile package to launch') + .option( + '--require-capability ', + 'Require provider runtime capabilities before launch' + ) + .option( + '--commit-policy ', + 'Commit policy for this run (forbidden, allowed, or required)' + ) + .option('--json', 'Output as JSON'); + + if (action === 'fork') { + command.option('--fork-turn ', 'Provider turn boundary to fork from'); + } + + command.action(async (id: string, options: ConversationTurnOptions) => { + try { + const taskId = await resolveTaskId(id); + const result = await api<{ + attemptId: string; + conversation?: ConversationLifecycleRecord; + }>(`/api/agents/${taskId}/conversation/${action}`, { + method: 'POST', + body: JSON.stringify({ + sourceAttemptId: options.sourceAttempt, + message: options.message, + ...(action === 'fork' && options.forkTurn ? { forkTurnId: options.forkTurn } : {}), + profileId: options.profile, + requiredRuntimeCapabilities: options.requireCapability, + commitPolicy: options.commitPolicy, + }), + }); + printConversationResult(action, result, options.json); + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); +} + +function registerConversationControlCommand( + program: Command, + action: ConversationControlAction, + description: string +): void { + program + .command(`agent:${action} `) + .description(description) + .requiredOption('--attempt ', 'Exact active attempt ID') + .option('--json', 'Output as JSON') + .action(async (id: string, options: ConversationControlOptions) => { + try { + const taskId = await resolveTaskId(id); + const result = await api( + `/api/agents/${taskId}/conversation/${action}`, + { + method: 'POST', + body: JSON.stringify({ attemptId: options.attempt }), + } + ); + printConversationResult(action, result, options.json); + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + }); +} + export function registerAgentCommands(program: Command): void { // Start agent on task program @@ -293,6 +419,70 @@ export function registerAgentCommands(program: Command): void { } }); + registerConversationTurnCommand( + program, + 'resume', + 'Resume a terminal provider conversation without replaying prior prompts' + ); + registerConversationTurnCommand( + program, + 'follow-up', + 'Start a provider-native follow-up turn from a terminal attempt' + ); + registerConversationTurnCommand( + program, + 'fork', + 'Fork provider-native history from a terminal attempt' + ); + + program + .command('agent:steer ') + .description('Steer the exact active provider turn') + .requiredOption('--attempt ', 'Exact active attempt ID') + .requiredOption('-m, --message ', 'Steering message') + .option('--json', 'Output as JSON') + .action( + async ( + id: string, + options: ConversationControlOptions & { message: string } + ): Promise => { + try { + const taskId = await resolveTaskId(id); + const result = await api( + `/api/agents/${taskId}/conversation/steer`, + { + method: 'POST', + body: JSON.stringify({ + attemptId: options.attempt, + message: options.message, + }), + } + ); + printConversationResult('steered', result, options.json); + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } + } + ); + + registerConversationControlCommand( + program, + 'interrupt', + 'Interrupt the exact active provider turn' + ); + registerConversationControlCommand( + program, + 'compact', + 'Compact the active provider conversation' + ); + registerConversationControlCommand( + program, + 'archive', + 'Archive the active provider conversation' + ); + registerConversationControlCommand(program, 'close', 'Close the active provider conversation'); + // Get pending agent requests (for Veritas to process) program .command('agents:pending') diff --git a/docs/AGENT-PROVIDERS.md b/docs/AGENT-PROVIDERS.md index c16f421c..45ddc910 100644 --- a/docs/AGENT-PROVIDERS.md +++ b/docs/AGENT-PROVIDERS.md @@ -42,8 +42,10 @@ sandboxes. Edit and Write require a writable sandbox. Bash requires both a writable sandbox and network access. WebFetch and WebSearch are denied when network access is disabled, and sensitive environment, secret, and credential file patterns are denied. Caller arguments cannot replace these controls, -inherit settings/plugins/MCP configuration, resume an unrelated session, or -bypass permissions. +inherit settings/plugins/MCP configuration, select an unrelated session, or +bypass permissions. Veritas may append system-owned `--resume ` +and `--fork-session` only after the provider-neutral lifecycle validator binds +the exact source attempt. `--bare` deliberately skips Claude Code's local settings, plugins, MCP servers, OAuth, and keychain state. Veritas therefore copies the worktree's `AGENTS.md` @@ -63,7 +65,7 @@ Readiness runs bounded `claude --version`, `claude auth status`, and `claude agents --json` probes without a shell. The auth status probe is useful diagnostic evidence, but only the explicit bare-mode environment satisfies launch authentication. The runtime profile requires an exact -`2.1.218 (Claude Code)` version match and probe revision 5; version or build +`2.1.218 (Claude Code)` version match and probe revision 8; version or build drift invalidates conformance evidence. Claude's stream is consumed as bounded JSONL. Partial text/thinking, tool @@ -75,10 +77,11 @@ unterminated record after process close, preventing the terminal result from being lost during stream drain. A successful process exit without an authoritative successful `result` record fails closed. -Resume/fork, interactive approvals, elicitation, and run-scoped MCP injection -remain explicitly unsupported until their provider-neutral lifecycle and -broker issues land. Static `dontAsk` permissions are the only accepted launch -posture in this adapter version. +Resume and follow-up use the exact persisted `session_id`; fork combines +`--resume` with `--fork-session` so the source history is not mutated. +Interactive approvals, elicitation, steering, compaction, archival, and +run-scoped MCP injection remain explicitly unsupported. Static `dontAsk` +permissions are the only accepted launch posture in this adapter version. Credential-gated release smoke is opt-in: @@ -103,10 +106,10 @@ Veritas supports three distinct Codex execution roles: The app-server adapter is pinned to `codex-cli 0.145.0`, upstream tag `rust-v0.145.0`, commit `25af12f7e61572b0bc18ddb1008be543b91519b0`. Its checked-in schemas were -generated by that exact executable. The combined retained schema-set digest is +generated by that exact executable. The certified generated schema-set digest is `b59f4df6df8d00b3e665b533416efcfef9b5530bcd22a1e4a15dfe7bbd3a8624`. Version or build drift invalidates conformance evidence at provider probe -revision 7. +revision 8. Veritas owns the launch: @@ -124,14 +127,15 @@ allowlist while forcing `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`. Live certification requires the app-server to report remote-control status `disabled`. -The outbound method allowlist contains only `initialize`, `thread/start`, -`turn/start`, and `turn/interrupt`. Veritas initializes once, starts one -task-bound thread and turn, persists the thread identity, validates every -consumed request, response, notification, and provider request, and maps -streamed items, usage, file changes, and authoritative `turn/completed` into -the causal run journal and completion contract. JSONL records are capped at -4 MiB. Uncorrelated or schema-invalid records fail closed. Documented overload -error `-32001` receives bounded exponential backoff. +The outbound method allowlist contains `initialize`, thread +start/resume/fork/compact/archive, and turn start/steer/interrupt. Veritas +initializes once, binds one task attempt to an exact thread and active turn, +persists thread/turn/item identity, validates every consumed request, response, +notification, and provider request, and maps streamed items, usage, file +changes, and authoritative `turn/completed` into the causal run journal and +completion contract. JSONL records are capped at 4 MiB. Uncorrelated or +schema-invalid records fail closed. Documented overload error `-32001` receives +bounded exponential backoff. The upstream `thread/shellCommand` method runs unsandboxed and is structurally unreachable from Veritas. Command, file-change, permission, legacy exec/patch, @@ -143,11 +147,12 @@ interruption, cancellation, stale evidence, and response errors fail closed. Only tool questions are marked mobile-safe by this adapter; shell, filesystem, network, permission, and MCP requests require a non-mobile reviewer. -Resume, follow-up, steer, fork, compact, archive, and crash reattachment remain -visibly unsupported until #853 and #856 provide the provider-neutral lifecycle -and ownership controls. Inherited MCP servers, plugins, apps, hooks, -browser/computer tools, and remote control remain disabled until those controls -can be represented in the immutable run manifest. +Resume, follow-up, steer, fork, compact, archive, close, and crash reattachment +are capability-gated against the immutable runtime evidence. Native history +forks retain the parent conversation and optional source-turn boundary without +mutating the source. Inherited MCP servers, plugins, apps, hooks, +browser/computer tools, and remote control remain disabled; issue #857 owns the +run-scoped tool-server control plane. Credential-gated release smoke is opt-in: @@ -241,8 +246,9 @@ are not positively cached. Capability states describe behavior that the current adapter actually proves. They do not imply that adjacent roadmap work already exists. For example, -provider-neutral approvals, reattachment, follow-up/fork/steer controls, and MCP -governance remain unsupported or unknown until their dedicated issues land. +provider-neutral controls remain unsupported or unknown for an adapter until +the runtime exposes a verified native operation. MCP governance remains +unsupported until its dedicated issue lands. One evaluator maps those capabilities to launch and run controls. Agent starts always require `run.start`, `run.status`, `run.logs`, `run.complete`, and @@ -259,6 +265,39 @@ and artifact ingestion compare the active and persisted manifest digests before acting. Task Detail, Work view, and shared co-drive messaging disable actions that the manifest does not support and show the evaluator's reason. +## Provider-Neutral Conversation Lifecycle + +`conversation-lifecycle/v1` is stored on every attempt. It records opaque +provider conversation, turn, and item IDs; parent attempt/conversation and fork +turn; lifecycle state; and measured context-window utilization. It deliberately +does not store process handles, credentials, leases, or other transient +authority. + +| Task adapter | Resume/follow-up | Native fork | In-flight steer | Compact/archive | +| ------------------ | ---------------- | ----------- | --------------- | --------------- | +| Codex CLI | Supported | Unsupported | Unsupported | Unsupported | +| Codex SDK | Supported | Unsupported | Unsupported | Unsupported | +| Codex app-server | Supported | Supported | Supported | Supported | +| Claude Code | Supported | Supported | Unsupported | Unsupported | +| Hermes | Unsupported | Unsupported | Unsupported | Unsupported | +| OpenClaw task mode | Unsupported | Unsupported | Unsupported | Unsupported | + +Resume requires the exact source worktree. Fork permits a new worktree only +when repository and base revision remain compatible. Both operations compare +the source and target provider, adapter protocol, model, sandbox, tool, +permission, and launch-policy evidence before attempt state is created. +Restart-to-resume uses persisted provider identity and the durable supervisor +event cursor; it never replays prior effectful prompts. + +Lifecycle mutations are available at +`POST /api/agents/:taskId/conversation/{action}` and through +`vk agent:{action}` or the MCP `control_agent_conversation` tool. Supported +actions are resume, follow-up, fork, steer, interrupt, compact, archive, and +close. Every accepted action has auth-derived attribution and a causal +`conversation.*` journal event. Unsupported steering fails the capability +gate; any recorded-only fallback returns `delivered: false`. Generic process +stdin is not treated as provider delivery. + Agents and supervisors can register the same validated manifest with `POST /api/agents/register` and refresh it through the heartbeat endpoint. Host provider, model, `tool.*`, and sandbox posture is derived only from those diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index e952cce2..b13781f9 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -2306,6 +2306,39 @@ Current task adapters reject every non-empty named-tool or MCP catalog. The run-scoped tool-server control plane tracked in #857 owns positive catalog injection; prompt text is never accepted as equivalent enforcement. +### Conversation Lifecycle + +``` +POST /api/agents/:taskId/conversation/resume +POST /api/agents/:taskId/conversation/follow-up +POST /api/agents/:taskId/conversation/fork +POST /api/agents/:taskId/conversation/steer +POST /api/agents/:taskId/conversation/interrupt +POST /api/agents/:taskId/conversation/compact +POST /api/agents/:taskId/conversation/archive +POST /api/agents/:taskId/conversation/close +``` + +Resume, follow-up, and fork require a terminal source attempt with durable +provider identity: + +```json +{ + "sourceAttemptId": "attempt_parent", + "message": "Continue from the verified history", + "forkTurnId": "turn_7", + "commitPolicy": "allowed" +} +``` + +`forkTurnId` is accepted only for fork. The remaining controls require the +exact active `attemptId`; steer also requires `message`. Every action validates +capability and immutable launch evidence before invoking a provider-native +operation. Unsupported steering fails with an explicit capability error, and +any recorded-only fallback returns `delivered: false` with a reason. Veritas +does not claim that recording a message or writing process stdin reached the +provider. + `commitPolicy` accepts `forbidden`, `allowed`, or `required`. A run value overrides `task.executionPolicy.commitPolicy`, then the legacy `features.agents.autoCommitOnComplete` setting. Legacy `true` maps to @@ -2327,7 +2360,7 @@ controls: "providerRuntime": { "digest": "sha256:...", "provider": "codex-cli", - "probeRevision": 5 + "probeRevision": 8 }, "runtime": { "command": "codex", diff --git a/docs/CLI-GUIDE.md b/docs/CLI-GUIDE.md index 2ca6378e..ffc4820e 100644 --- a/docs/CLI-GUIDE.md +++ b/docs/CLI-GUIDE.md @@ -450,6 +450,14 @@ Manage AI agents on code tasks. | `vk start ` | Start an agent; optionally require runtime capabilities | | `vk launch-preview ` | Preview effective launch inputs, blockers, and drift | | `vk stop ` | Stop a run only when its persisted manifest supports stop | +| `vk agent:resume --source-attempt -m ` | Resume the exact persisted provider conversation | +| `vk agent:follow-up --source-attempt -m ` | Start a provider-native follow-up turn | +| `vk agent:fork --source-attempt -m ` | Fork provider history without mutating its source | +| `vk agent:steer --attempt -m ` | Steer the exact active provider turn | +| `vk agent:interrupt --attempt ` | Interrupt the exact active provider turn | +| `vk agent:compact --attempt ` | Compact a supported provider conversation | +| `vk agent:archive --attempt ` | Archive a supported provider conversation | +| `vk agent:close --attempt ` | Close a supported provider conversation | | `vk agents:pending` | List pending agent requests | | `vk agents:status ` | Check agent running status | | `vk agents:complete -s --attempt-id --manifest-digest ` | Mark the matching agent attempt complete (success) | @@ -486,6 +494,13 @@ It overrides a task default and the legacy auto-commit setting. Omitting the flag keeps existing tasks compatible: commits are allowed but not required unless a task or legacy setting explicitly requires one. +Lifecycle commands fail closed from the persisted runtime manifest. Resume +requires the exact source worktree; fork permits a compatible worktree at the +same repository and base revision. `--fork-turn ` selects an optional +provider-native history boundary. Unsupported controls preserve the server's +reason, and a recorded operator message is never reported as delivered unless +the adapter executed a verified native steering operation. + Use `vk agents:status TASK-001 --json` to inspect the persisted manifest and capability-derived `controls` set. `vk stop` does not infer support from the agent name. It resolves the current `attemptId` from status and includes it in diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 29c8d85c..11f524c0 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -369,13 +369,13 @@ Implemented: successful provider result fails closed. - **Session continuity evidence** — Claude `session_id` is stored on the attempt and separately from turn/item identity in the event schema. -- **Versioned readiness** — The exact v2.1.218 runtime, probe revision 5, +- **Versioned readiness** — The exact v2.1.218 runtime, probe revision 8, authentication posture, and safe agent-discovery summary determine support status. - **Capability truth** — The shared approval broker is available, but this Claude adapter still uses static `dontAsk` permissions and reports - interactive approval and elicitation as unsupported. Resume/fork and MCP - injection also remain unsupported. + interactive approval and elicitation as unsupported. Exact-session resume + and native fork are supported; steering and MCP injection remain unsupported. See [Agent Providers](AGENT-PROVIDERS.md#claude-code-v21218) for setup, credentials, arguments, permissions, and limitations. @@ -1687,6 +1687,14 @@ Added in v3.3.2. | `vk start ` | Start an agent on a code task (`--agent` to choose) | | `vk launch-preview ` | Preview immutable launch evidence without dispatch | | `vk stop ` | Stop a running agent | +| `vk agent:resume --source-attempt -m ` | Resume an exact provider conversation | +| `vk agent:follow-up --source-attempt -m ` | Start a native follow-up turn | +| `vk agent:fork --source-attempt -m ` | Fork native provider history | +| `vk agent:steer --attempt -m ` | Steer the exact active provider turn | +| `vk agent:interrupt --attempt ` | Interrupt the exact active attempt | +| `vk agent:compact --attempt ` | Compact a supported provider conversation | +| `vk agent:archive --attempt ` | Archive a supported provider conversation | +| `vk agent:close --attempt ` | Close a supported provider conversation | | `vk agents:pending` | List pending agent requests | | `vk agents:status ` | Check agent running status | | `vk agents:complete -s --attempt-id --manifest-digest ` | Mark the matching agent attempt complete (success) | diff --git a/docs/security/permission-coverage.json b/docs/security/permission-coverage.json index 1019cf1d..10462b67 100644 --- a/docs/security/permission-coverage.json +++ b/docs/security/permission-coverage.json @@ -651,6 +651,70 @@ "source": "cli/src/commands/agents.ts", "denialReason": "Stopping an agent requires task read and task write access." }, + { + "id": "cli:agents:agent:resume", + "kind": "cli", + "classification": "agent-scoped", + "permissions": ["task:read", "agent:write"], + "source": "cli/src/commands/agents.ts", + "denialReason": "Resuming a provider conversation requires task read and agent write access." + }, + { + "id": "cli:agents:agent:follow-up", + "kind": "cli", + "classification": "agent-scoped", + "permissions": ["task:read", "agent:write"], + "source": "cli/src/commands/agents.ts", + "denialReason": "Starting a follow-up turn requires task read and agent write access." + }, + { + "id": "cli:agents:agent:fork", + "kind": "cli", + "classification": "agent-scoped", + "permissions": ["task:read", "agent:write"], + "source": "cli/src/commands/agents.ts", + "denialReason": "Forking provider history requires task read and agent write access." + }, + { + "id": "cli:agents:agent:steer", + "kind": "cli", + "classification": "agent-scoped", + "permissions": ["task:read", "task:write"], + "source": "cli/src/commands/agents.ts", + "denialReason": "Steering an active provider turn requires task read and task write access." + }, + { + "id": "cli:agents:agent:interrupt", + "kind": "cli", + "classification": "agent-scoped", + "permissions": ["task:read", "agent:write"], + "source": "cli/src/commands/agents.ts", + "denialReason": "Interrupting an active provider turn requires task read and agent write access." + }, + { + "id": "cli:agents:agent:compact", + "kind": "cli", + "classification": "agent-scoped", + "permissions": ["task:read", "agent:write"], + "source": "cli/src/commands/agents.ts", + "denialReason": "Compacting a provider conversation requires task read and agent write access." + }, + { + "id": "cli:agents:agent:archive", + "kind": "cli", + "classification": "agent-scoped", + "permissions": ["task:read", "agent:write"], + "source": "cli/src/commands/agents.ts", + "denialReason": "Archiving a provider conversation requires task read and agent write access." + }, + { + "id": "cli:agents:agent:close", + "kind": "cli", + "classification": "agent-scoped", + "permissions": ["task:read", "agent:write"], + "source": "cli/src/commands/agents.ts", + "denialReason": "Closing a provider conversation requires task read and agent write access." + }, { "id": "cli:agents:profiles", "kind": "cli", @@ -715,6 +779,14 @@ "source": "cli/src/commands/agents.ts", "denialReason": "Agent request status requires agent read access." }, + { + "id": "mcp:control_agent_conversation", + "kind": "mcp-tool", + "classification": "agent-scoped", + "permissions": ["agent:write", "task:write"], + "source": "mcp/src/tools/agents.ts", + "denialReason": "Conversation lifecycle controls require agent or task write access according to the selected action." + }, { "id": "cli:automation:automation:pending", "kind": "cli", diff --git a/mcp/src/__tests__/agent-tools.test.ts b/mcp/src/__tests__/agent-tools.test.ts index ad0bfd4e..8478da5b 100644 --- a/mcp/src/__tests__/agent-tools.test.ts +++ b/mcp/src/__tests__/agent-tools.test.ts @@ -38,6 +38,13 @@ describe('MCP agent runtime capability controls', () => { }); }); + it('publishes provider-neutral lifecycle actions in one MCP tool', () => { + const control = agentTools.find((tool) => tool.name === 'control_agent_conversation'); + expect(control?.inputSchema.properties.action).toMatchObject({ + enum: ['resume', 'follow-up', 'fork', 'steer', 'interrupt', 'compact', 'archive', 'close'], + }); + }); + it('forwards a parent attempt for material launch drift', async () => { await handleAgentTool('start_agent', { id: 'task_1', @@ -110,4 +117,47 @@ describe('MCP agent runtime capability controls', () => { body: JSON.stringify({ attemptId: 'attempt_1' }), }); }); + + it('forwards a native history fork with its source turn boundary', async () => { + await handleAgentTool('control_agent_conversation', { + id: 'task_1', + action: 'fork', + sourceAttemptId: 'attempt_parent', + message: 'Try the alternate implementation', + forkTurnId: 'turn_5', + }); + + expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/conversation/fork', { + method: 'POST', + body: JSON.stringify({ + sourceAttemptId: 'attempt_parent', + message: 'Try the alternate implementation', + forkTurnId: 'turn_5', + }), + }); + }); + + it('requires exact active-attempt provenance for in-flight controls', async () => { + await expect( + handleAgentTool('control_agent_conversation', { + id: 'task_1', + action: 'steer', + message: 'Use the smaller patch', + }) + ).rejects.toThrow('steer requires attemptId'); + + await handleAgentTool('control_agent_conversation', { + id: 'task_1', + action: 'steer', + attemptId: 'attempt_1', + message: 'Use the smaller patch', + }); + expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/conversation/steer', { + method: 'POST', + body: JSON.stringify({ + attemptId: 'attempt_1', + message: 'Use the smaller patch', + }), + }); + }); }); diff --git a/mcp/src/tools/agents.ts b/mcp/src/tools/agents.ts index 97972a10..0abb9ce0 100644 --- a/mcp/src/tools/agents.ts +++ b/mcp/src/tools/agents.ts @@ -22,6 +22,68 @@ const TaskIdSchema = z.object({ id: z.string().min(1), }); +const ConversationActionSchema = z.enum([ + 'resume', + 'follow-up', + 'fork', + 'steer', + 'interrupt', + 'compact', + 'archive', + 'close', +]); + +const ConversationControlSchema = z + .object({ + id: z.string().min(1), + action: ConversationActionSchema, + sourceAttemptId: z.string().trim().min(1).max(120).optional(), + attemptId: z.string().trim().min(1).max(120).optional(), + message: z.string().trim().min(1).max(20_000).optional(), + forkTurnId: z.string().trim().min(1).max(240).optional(), + }) + .strict() + .superRefine((value, context) => { + if (['resume', 'follow-up', 'fork'].includes(value.action)) { + if (!value.sourceAttemptId) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['sourceAttemptId'], + message: `${value.action} requires sourceAttemptId`, + }); + } + if (!value.message) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['message'], + message: `${value.action} requires message`, + }); + } + } else { + if (!value.attemptId) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['attemptId'], + message: `${value.action} requires attemptId`, + }); + } + if (value.action === 'steer' && !value.message) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['message'], + message: 'steer requires message', + }); + } + } + if (value.forkTurnId && value.action !== 'fork') { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['forkTurnId'], + message: 'forkTurnId is only valid for fork', + }); + } + }); + export const agentTools = [ { name: 'start_agent', @@ -70,6 +132,42 @@ export const agentTools = [ required: ['id'], }, }, + { + name: 'control_agent_conversation', + description: + 'Resume, follow up, fork, steer, interrupt, compact, archive, or close a provider conversation using verified native controls', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Task ID or partial ID', + }, + action: { + type: 'string', + enum: ConversationActionSchema.options, + description: 'Provider-neutral lifecycle action', + }, + sourceAttemptId: { + type: 'string', + description: 'Terminal source attempt for resume, follow-up, or fork', + }, + attemptId: { + type: 'string', + description: 'Exact active attempt for steer, interrupt, compact, archive, or close', + }, + message: { + type: 'string', + description: 'New-turn or steering message', + }, + forkTurnId: { + type: 'string', + description: 'Optional provider turn boundary for a native history fork', + }, + }, + required: ['id', 'action'], + }, + }, ]; export async function handleAgentTool(name: string, args: any): Promise { @@ -151,6 +249,38 @@ export async function handleAgentTool(name: string, args: any): Promise { }; } + case 'control_agent_conversation': { + const { id, action, sourceAttemptId, attemptId, message, forkTurnId } = + ConversationControlSchema.parse(args); + const task = await findTask(id); + if (!task) { + return { + content: [{ type: 'text', text: `Task not found: ${id}` }], + isError: true, + }; + } + + const startsTurn = ['resume', 'follow-up', 'fork'].includes(action); + const body = startsTurn + ? { + sourceAttemptId, + message, + ...(action === 'fork' && forkTurnId ? { forkTurnId } : {}), + } + : { + attemptId, + ...(action === 'steer' ? { message } : {}), + }; + const result = await api(`/api/agents/${task.id}/conversation/${action}`, { + method: 'POST', + body: JSON.stringify(body), + }); + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + default: throw new Error(`Unknown agent tool: ${name}`); } diff --git a/server/src/__tests__/claude-code-provider.test.ts b/server/src/__tests__/claude-code-provider.test.ts index 792fe7fd..e904487f 100644 --- a/server/src/__tests__/claude-code-provider.test.ts +++ b/server/src/__tests__/claude-code-provider.test.ts @@ -71,6 +71,42 @@ describe('Claude Code v2.1.218 adapter contract', () => { } }); + it('builds provider-owned resume and fork invocations from exact session identities', () => { + const resumed = buildClaudeCodeArgs({ + prompt: 'Continue with the focused fix.', + resumeSessionId: '11111111-1111-4111-8111-111111111111', + sandboxMode: 'workspace-write', + networkAccessEnabled: false, + }); + expect(resumed.slice(-3)).toEqual([ + '--resume', + '11111111-1111-4111-8111-111111111111', + 'Continue with the focused fix.', + ]); + + const forked = buildClaudeCodeArgs({ + prompt: 'Explore the alternate implementation.', + resumeSessionId: '11111111-1111-4111-8111-111111111111', + forkSession: true, + sandboxMode: 'workspace-write', + networkAccessEnabled: false, + }); + expect(forked.slice(-4)).toEqual([ + '--resume', + '11111111-1111-4111-8111-111111111111', + '--fork-session', + 'Explore the alternate implementation.', + ]); + expect(() => + buildClaudeCodeArgs({ + prompt: 'invalid', + forkSession: true, + sandboxMode: 'workspace-write', + networkAccessEnabled: false, + }) + ).toThrow('requires an exact source session ID'); + }); + it('passes only explicit Claude Code credentials and safe process context', () => { const source = { HOME: '/home/operator', @@ -189,7 +225,7 @@ describe('Claude Code v2.1.218 adapter contract', () => { }); }); - it('publishes fail-closed capabilities until shared lifecycle and approval brokers land', () => { + it('publishes verified lifecycle capabilities while keeping unbrokered surfaces closed', () => { const capabilities = getProviderRuntimeAdapterDefinition('claude-code').capabilities; const state = (id: string) => capabilities.find((capability) => capability.id === id)?.state; @@ -197,7 +233,9 @@ describe('Claude Code v2.1.218 adapter contract', () => { expect(state('run.streaming')).toBe('supported'); expect(state('run.structured-events')).toBe('supported'); expect(state('usage.tokens')).toBe('supported'); - expect(state('run.resume')).toBe('advisory'); + expect(state('run.follow-up')).toBe('supported'); + expect(state('run.resume')).toBe('supported'); + expect(state('run.fork')).toBe('supported'); expect(state('run.approvals')).toBe('unsupported'); expect(state('run.elicitation')).toBe('unsupported'); expect(state('tool.mcp')).toBe('unsupported'); diff --git a/server/src/__tests__/codex-app-server-provider.test.ts b/server/src/__tests__/codex-app-server-provider.test.ts index fb9f8d53..926f3d2f 100644 --- a/server/src/__tests__/codex-app-server-provider.test.ts +++ b/server/src/__tests__/codex-app-server-provider.test.ts @@ -16,6 +16,7 @@ import { normalizeHarnessSupportProfile } from '../services/harness-support-prof import { getProviderRuntimeAdapterDefinition } from '../services/provider-runtime-adapter-registry.js'; const THREAD_ID = '019f8f31-b3e2-7240-8108-1e389af04f0e'; +const FORK_THREAD_ID = '019f8f31-b3e2-7240-8108-1e389af04f10'; const TURN_ID = '019f8f31-b3e2-7240-8108-1e389af04f0f'; function initializeResult() { @@ -27,7 +28,7 @@ function initializeResult() { }; } -function threadStartResult() { +function threadStartResult(threadId = THREAD_ID) { return { approvalPolicy: 'never', approvalsReviewer: 'user', @@ -40,10 +41,10 @@ function threadStartResult() { createdAt: 1, cwd: '/tmp/worktree', ephemeral: false, - id: THREAD_ID, + id: threadId, modelProvider: 'openai', preview: 'test prompt', - sessionId: THREAD_ID, + sessionId: threadId, source: 'appServer', status: { type: 'idle' }, turns: [], @@ -116,7 +117,12 @@ describe('Codex app-server v2 provider', () => { expect(CODEX_APP_SERVER_OUTBOUND_METHODS).toEqual([ 'initialize', 'thread/start', + 'thread/resume', + 'thread/fork', + 'thread/compact/start', + 'thread/archive', 'turn/start', + 'turn/steer', 'turn/interrupt', ]); expect(isCodexAppServerOutboundMethod('thread/shellCommand')).toBe(false); @@ -237,6 +243,79 @@ describe('Codex app-server v2 provider', () => { await rejectedThread; }); + it('uses exact native resume, fork, steer, compact, and archive controls', async () => { + const writes: string[] = []; + const client = await initializeClient(writes); + + const resume = client.resumeThread({ + threadId: THREAD_ID, + cwd: '/tmp/worktree', + model: 'gpt-5.6', + sandboxMode: 'workspace-write', + }); + expect(parseLastWrite(writes)).toMatchObject({ + id: 2, + method: 'thread/resume', + params: { + threadId: THREAD_ID, + cwd: '/tmp/worktree', + excludeTurns: true, + }, + }); + await client.acceptRecord({ id: 2, result: threadStartResult() }); + await expect(resume).resolves.toBe(THREAD_ID); + + const fork = client.forkThread({ + threadId: THREAD_ID, + lastTurnId: TURN_ID, + cwd: '/tmp/worktree-child', + sandboxMode: 'workspace-write', + }); + expect(parseLastWrite(writes)).toMatchObject({ + id: 3, + method: 'thread/fork', + params: { + threadId: THREAD_ID, + lastTurnId: TURN_ID, + deferGoalContinuation: true, + excludeTurns: true, + }, + }); + await client.acceptRecord({ id: 3, result: threadStartResult(FORK_THREAD_ID) }); + await expect(fork).resolves.toBe(FORK_THREAD_ID); + + const steer = client.steer(FORK_THREAD_ID, TURN_ID, 'Check the focused regression.'); + expect(parseLastWrite(writes)).toEqual({ + id: 4, + method: 'turn/steer', + params: { + threadId: FORK_THREAD_ID, + expectedTurnId: TURN_ID, + input: [{ type: 'text', text: 'Check the focused regression.' }], + }, + }); + await client.acceptRecord({ id: 4, result: { turnId: TURN_ID } }); + await expect(steer).resolves.toBe(TURN_ID); + + const compact = client.compact(FORK_THREAD_ID); + expect(parseLastWrite(writes)).toEqual({ + id: 5, + method: 'thread/compact/start', + params: { threadId: FORK_THREAD_ID }, + }); + await client.acceptRecord({ id: 5, result: {} }); + await expect(compact).resolves.toBeUndefined(); + + const archive = client.archive(FORK_THREAD_ID); + expect(parseLastWrite(writes)).toEqual({ + id: 6, + method: 'thread/archive', + params: { threadId: FORK_THREAD_ID }, + }); + await client.acceptRecord({ id: 6, result: {} }); + await expect(archive).resolves.toBeUndefined(); + }); + it('retries overload responses with a bounded deterministic budget', async () => { const writes: string[] = []; const sleep = vi.fn(async () => {}); @@ -488,7 +567,11 @@ describe('Codex app-server v2 provider', () => { ); expect(adapter.capabilities.find(({ id }) => id === 'run.start')?.state).toBe('supported'); expect(adapter.capabilities.find(({ id }) => id === 'run.interrupt')?.state).toBe('supported'); - expect(adapter.capabilities.find(({ id }) => id === 'run.resume')?.state).toBe('unsupported'); + expect(adapter.capabilities.find(({ id }) => id === 'run.follow-up')?.state).toBe('supported'); + expect(adapter.capabilities.find(({ id }) => id === 'run.steer')?.state).toBe('supported'); + expect(adapter.capabilities.find(({ id }) => id === 'run.resume')?.state).toBe('supported'); + expect(adapter.capabilities.find(({ id }) => id === 'run.fork')?.state).toBe('supported'); + expect(adapter.capabilities.find(({ id }) => id === 'run.compact')?.state).toBe('supported'); expect(adapter.capabilities.find(({ id }) => id === 'run.approvals')?.state).toBe('supported'); expect(adapter.capabilities.find(({ id }) => id === 'tool.mcp')?.state).toBe('unsupported'); }); diff --git a/server/src/__tests__/conversation-lifecycle-service.test.ts b/server/src/__tests__/conversation-lifecycle-service.test.ts new file mode 100644 index 00000000..eacfe8d6 --- /dev/null +++ b/server/src/__tests__/conversation-lifecycle-service.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest'; +import type { + ConversationLifecycleRecord, + RunLaunchManifest, + TaskAttempt, + TaskEnvelope, +} from '@veritas-kanban/shared'; +import { ConversationLifecycleService } from '../services/conversation-lifecycle-service.js'; + +const NOW = new Date('2026-07-24T12:00:00.000Z'); + +function service() { + return new ConversationLifecycleService(() => NOW); +} + +function manifest( + overrides: { + provider?: string; + adapter?: string; + protocolVersion?: string; + runtimeMaterialDigest?: string; + model?: string; + worktreeId?: string; + baseCommit?: string; + } = {} +): RunLaunchManifest { + return { + taskEnvelope: { + schemaVersion: 'task-envelope/v1', + digest: 'sha256:task-envelope', + materialDigest: 'sha256:task-envelope-material', + }, + providerRuntime: { + provider: overrides.provider ?? 'codex-app-server', + adapter: overrides.adapter ?? 'codex-app-server', + protocolVersion: overrides.protocolVersion ?? 'codex-app-server-jsonrpc/v2', + materialDigest: overrides.runtimeMaterialDigest ?? 'sha256:runtime-material', + }, + providerRequirements: { required: [], capabilities: [] }, + harnessSupport: { + profileId: 'codex-app-server', + adapterId: 'codex-app-server', + transport: 'app-server', + supportTier: 'certified', + }, + routing: { + requestedAgent: 'codex-app-server', + selectedAgent: 'codex-app-server', + selectedHost: 'local', + reason: 'test', + fallbackAgent: null, + fallbackAllowed: false, + }, + runtime: { + model: overrides.model ?? 'gpt-5.6', + command: 'codex', + args: [], + workingDirectory: 'task-worktree', + worktree: 'required', + environmentKeys: ['PATH'], + credentialReferences: [], + }, + instructions: [], + sandbox: { + effective: { + sandboxMode: 'workspace-write', + networkAccessEnabled: false, + environmentKeys: ['PATH'], + credentialReferences: [], + }, + }, + tools: { + allowed: [], + denied: [], + policyIds: [], + mcpServers: [], + enforcement: 'not-required', + }, + permissions: { + level: 'specialist', + required: [], + enforcement: 'not-required', + }, + resources: { skills: [], shared: [], enforcement: 'not-required' }, + requiredHealthChecks: [], + budget: { enabled: false }, + workspaceTrust: { status: 'trusted', source: 'test' }, + workspace: { + worktreeId: overrides.worktreeId ?? 'worktree-a', + worktreeManifestId: overrides.worktreeId ?? 'worktree-a', + repo: 'BradGroux/veritas-kanban', + branch: 'feat/source', + baseBranch: 'main', + resolvedBaseCommit: overrides.baseCommit ?? 'abc123', + baseResolutionSource: 'remote', + }, + } as RunLaunchManifest; +} + +function taskEnvelope( + overrides: { commitPolicy?: 'forbidden' | 'allowed' | 'required' } = {} +): TaskEnvelope { + return { + commitPolicy: overrides.commitPolicy ?? 'allowed', + allowedSideEffects: [], + } as TaskEnvelope; +} + +function attempt(overrides: Partial = {}): TaskAttempt { + return { + id: 'attempt-source', + agent: 'codex', + status: 'complete', + provider: 'codex-app-server', + model: 'gpt-5.6', + threadId: 'thread-source', + providerRuntimeManifest: { + provider: 'codex-app-server', + adapter: 'codex-app-server', + }, + taskEnvelope: taskEnvelope(), + runLaunchManifest: manifest(), + ...overrides, + } as TaskAttempt; +} + +describe('ConversationLifecycleService', () => { + it('resolves a legacy durable thread without inheriting transient authority', () => { + const source = service().source( + attempt({ + sessionKey: 'transient-session-key', + runSupervisorId: 'transient-supervisor', + }), + 'resume' + ); + const record = service().create('resume', source); + + expect(record).toMatchObject({ + schemaVersion: 'conversation-lifecycle/v1', + mode: 'resume', + intent: 'resume', + conversationId: 'thread-source', + parentConversationId: 'thread-source', + parentAttemptId: 'attempt-source', + state: 'active', + }); + expect(record).not.toHaveProperty('sessionKey'); + expect(record).not.toHaveProperty('runSupervisorId'); + }); + + it('recovers persisted lifecycle identity after restart and derives legacy identity once', () => { + const lifecycle = service(); + const persisted = lifecycle.create('resume', lifecycle.source(attempt(), 'resume')); + expect(lifecycle.recover(attempt({ conversation: persisted }))).toBe(persisted); + expect(lifecycle.recover(attempt({ threadId: undefined }), 'supervisor-session')).toMatchObject( + { + mode: 'fresh', + intent: 'fresh', + conversationId: 'supervisor-session', + } + ); + }); + + it('fails closed when source history is missing, active, closed, or lacks launch evidence', () => { + expect(() => service().source(undefined, 'resume')).toThrow('not found'); + expect(() => service().source(attempt({ status: 'running' }), 'resume')).toThrow( + 'terminal source' + ); + expect(() => service().source(attempt({ status: 'pending' }), 'resume')).toThrow( + 'terminal source' + ); + expect(() => + service().source( + attempt({ + conversation: { + ...service().create('fresh'), + conversationId: 'closed-thread', + state: 'closed', + }, + }), + 'resume' + ) + ).toThrow('closed'); + expect(() => + service().source( + attempt({ + conversation: { + ...service().create('fresh'), + conversationId: 'archived-thread', + state: 'archived', + }, + }), + 'fork' + ) + ).toThrow('archived'); + expect(() => + service().source(attempt({ providerRuntimeManifest: undefined }), 'resume') + ).toThrow('runtime, task, and launch evidence'); + }); + + it('persists fork ancestry and the selected turn boundary', () => { + const source = service().source(attempt(), 'fork'); + expect(service().create('fork', source, 'turn-42')).toMatchObject({ + mode: 'fork', + parentConversationId: 'thread-source', + parentAttemptId: 'attempt-source', + forkTurnId: 'turn-42', + }); + }); + + it('accepts an exact resume baseline and a same-baseline forked worktree', () => { + const lifecycle = service(); + const source = lifecycle.source(attempt(), 'resume'); + expect(() => + lifecycle.assertCompatible(source, manifest(), taskEnvelope(), 'resume') + ).not.toThrow(); + expect(() => + lifecycle.assertCompatible( + source, + manifest({ worktreeId: 'worktree-child' }), + taskEnvelope(), + 'fork' + ) + ).not.toThrow(); + }); + + it.each([ + ['provider', manifest({ provider: 'claude-code' })], + ['runtime evidence', manifest({ runtimeMaterialDigest: 'sha256:changed-runtime' })], + ['model', manifest({ model: 'gpt-5.5' })], + ['base commit', manifest({ baseCommit: 'def456' })], + ['resume worktree', manifest({ worktreeId: 'worktree-child' })], + ])('rejects incompatible %s evidence', (_label, target) => { + const lifecycle = service(); + const source = lifecycle.source(attempt(), 'resume'); + expect(() => lifecycle.assertCompatible(source, target, taskEnvelope(), 'resume')).toThrow( + 'incompatible' + ); + }); + + it('rejects resume when the current launch policy changes', () => { + const lifecycle = service(); + const source = lifecycle.source(attempt(), 'resume'); + expect(() => + lifecycle.assertCompatible( + source, + manifest(), + taskEnvelope({ commitPolicy: 'forbidden' }), + 'resume' + ) + ).toThrow('incompatible'); + }); + + it('binds provider conversation, turn, and item identities', () => { + const record = service().bind(service().create('fresh'), { + conversationId: 'thread-1', + turnId: 'turn-1', + itemId: 'item-1', + }); + expect(record).toMatchObject({ + conversationId: 'thread-1', + currentTurnId: 'turn-1', + lastItemId: 'item-1', + }); + }); + + it.each([ + [500, 1_000, 'healthy'], + [760, 1_000, 'nearing-limit'], + [950, 1_000, 'critical'], + [500, undefined, 'unknown'], + ] as const)('calculates context posture for %s/%s tokens', (used, limit, posture) => { + const record = service().recordContext(service().create('fresh'), used, limit); + expect(record.contextWindow).toMatchObject({ + usedTokens: used, + posture, + measuredAt: NOW.toISOString(), + }); + }); + + it.each(['compacted', 'archived', 'closed'] as const)( + 'records the %s terminal lifecycle timestamp', + (state) => { + const record = service().transition( + service().create('fresh'), + state + ) as ConversationLifecycleRecord; + expect(record.state).toBe(state); + expect(record[`${state}At` as 'compactedAt']).toBe(NOW.toISOString()); + } + ); + + it('preserves durable history identity and context evidence during compaction', () => { + const lifecycle = service(); + const record = lifecycle.recordContext( + lifecycle.bind(lifecycle.create('fork', lifecycle.source(attempt(), 'fork'), 'turn-4'), { + conversationId: 'thread-child', + turnId: 'turn-5', + itemId: 'item-8', + }), + 800, + 1_000 + ); + expect(lifecycle.transition(record, 'compacted')).toMatchObject({ + conversationId: 'thread-child', + currentTurnId: 'turn-5', + lastItemId: 'item-8', + parentConversationId: 'thread-source', + forkTurnId: 'turn-4', + contextWindow: { usedTokens: 800, limitTokens: 1_000 }, + state: 'compacted', + }); + }); +}); diff --git a/server/src/__tests__/routes/agents-local-capability.test.ts b/server/src/__tests__/routes/agents-local-capability.test.ts index 2ee5b77a..0f4a6513 100644 --- a/server/src/__tests__/routes/agents-local-capability.test.ts +++ b/server/src/__tests__/routes/agents-local-capability.test.ts @@ -8,7 +8,14 @@ const { mockStartAgent, mockPreviewAgentLaunch, mockStopAgent, + mockInterruptConversation, mockSendMessage, + mockResumeConversation, + mockFollowUpConversation, + mockForkConversation, + mockCompactConversation, + mockArchiveConversation, + mockCloseConversation, mockCompleteAgent, mockGetAgentStatus, mockAssertActiveRunControl, @@ -20,7 +27,14 @@ const { mockStartAgent: vi.fn(), mockPreviewAgentLaunch: vi.fn(), mockStopAgent: vi.fn(), + mockInterruptConversation: vi.fn(), mockSendMessage: vi.fn(), + mockResumeConversation: vi.fn(), + mockFollowUpConversation: vi.fn(), + mockForkConversation: vi.fn(), + mockCompactConversation: vi.fn(), + mockArchiveConversation: vi.fn(), + mockCloseConversation: vi.fn(), mockCompleteAgent: vi.fn(), mockGetAgentStatus: vi.fn(), mockAssertActiveRunControl: vi.fn(), @@ -43,7 +57,14 @@ vi.mock('../../services/clawdbot-agent-service.js', () => ({ startAgent: mockStartAgent, previewAgentLaunch: mockPreviewAgentLaunch, stopAgent: mockStopAgent, + interruptConversation: mockInterruptConversation, sendMessage: mockSendMessage, + resumeConversation: mockResumeConversation, + followUpConversation: mockFollowUpConversation, + forkConversation: mockForkConversation, + compactConversation: mockCompactConversation, + archiveConversation: mockArchiveConversation, + closeConversation: mockCloseConversation, completeAgent: mockCompleteAgent, getAgentStatus: mockGetAgentStatus, assertRunControl: vi.fn(), @@ -101,7 +122,42 @@ describe('agent local capability enforcement', () => { }, }); mockStopAgent.mockResolvedValue(undefined); + mockInterruptConversation.mockResolvedValue({ + action: 'interrupt', + attemptId: 'attempt_1', + delivered: true, + }); mockSendMessage.mockResolvedValue({ delivered: true, note: 'delivered' }); + mockResumeConversation.mockResolvedValue({ + taskId: 'task_1', + attemptId: 'attempt_2', + status: 'running', + }); + mockFollowUpConversation.mockResolvedValue({ + taskId: 'task_1', + attemptId: 'attempt_2', + status: 'running', + }); + mockForkConversation.mockResolvedValue({ + taskId: 'task_1', + attemptId: 'attempt_2', + status: 'running', + }); + mockCompactConversation.mockResolvedValue({ + action: 'compact', + attemptId: 'attempt_1', + delivered: true, + }); + mockArchiveConversation.mockResolvedValue({ + action: 'archive', + attemptId: 'attempt_1', + delivered: true, + }); + mockCloseConversation.mockResolvedValue({ + action: 'close', + attemptId: 'attempt_1', + delivered: true, + }); mockCompleteAgent.mockResolvedValue(undefined); mockGetAgentStatus.mockReturnValue(null); mockAssertActiveRunControl.mockResolvedValue(undefined); @@ -311,6 +367,73 @@ describe('agent local capability enforcement', () => { }); }); + it('exposes attributed provider-neutral conversation lifecycle routes', async () => { + const app = createApp( + auth({ + userId: 'operator_1', + clientMode: 'desktop-local', + capabilities: ['desktop:local'], + }) + ); + + const resume = await request(app).post('/api/agents/task_1/conversation/resume').send({ + sourceAttemptId: 'attempt_parent', + message: 'Continue from the durable provider history', + commitPolicy: 'forbidden', + }); + expect(resume.status).toBe(201); + expect(mockResumeConversation).toHaveBeenCalledWith( + 'task_1', + 'attempt_parent', + 'Continue from the durable provider history', + expect.objectContaining({ commitPolicy: 'forbidden' }) + ); + + const fork = await request(app).post('/api/agents/task_1/conversation/fork').send({ + sourceAttemptId: 'attempt_parent', + message: 'Explore another branch', + forkTurnId: 'turn_7', + }); + expect(fork.status).toBe(201); + expect(mockForkConversation).toHaveBeenCalledWith( + 'task_1', + 'attempt_parent', + 'Explore another branch', + 'turn_7', + expect.any(Object) + ); + + const spoofedSteer = await request(app).post('/api/agents/task_1/conversation/steer').send({ + attemptId: 'attempt_1', + message: 'Pretend another actor sent this', + actor: 'spoofed-operator', + }); + expect(spoofedSteer.status).toBe(400); + + const steer = await request(app).post('/api/agents/task_1/conversation/steer').send({ + attemptId: 'attempt_1', + message: 'Use the narrow fix', + }); + expect(steer.status).toBe(200); + expect(mockSendMessage).toHaveBeenCalledWith('task_1', 'Use the narrow fix', { + actor: 'operator_1', + source: 'conversation-route', + expectedAttemptId: 'attempt_1', + }); + + const compact = await request(app).post('/api/agents/task_1/conversation/compact').send({ + attemptId: 'attempt_1', + }); + expect(compact.status).toBe(200); + expect(mockCompactConversation).toHaveBeenCalledWith('task_1', 'attempt_1', 'operator_1'); + + const interrupt = await request(app).post('/api/agents/task_1/conversation/interrupt').send({ + attemptId: 'attempt_1', + }); + expect(interrupt.status).toBe(200); + expect(mockInterruptConversation).toHaveBeenCalledWith('task_1', 'attempt_1', 'operator_1'); + }); + it('requires and forwards completion attempt provenance', async () => { const app = createApp(auth()); const digest = `sha256:${'a'.repeat(64)}`; diff --git a/server/src/__tests__/shared-api-permissions.test.ts b/server/src/__tests__/shared-api-permissions.test.ts index 648a260e..000a833d 100644 --- a/server/src/__tests__/shared-api-permissions.test.ts +++ b/server/src/__tests__/shared-api-permissions.test.ts @@ -19,6 +19,29 @@ describe('shared API permission metadata', () => { ).toEqual(['agent:read']); }); + it('separates conversation steering from lifecycle mutation authority', () => { + expect( + getApiPermissionRequirement('/api/agents/task_1/conversation/steer', { + method: 'POST', + }).permissions + ).toEqual(['task:write']); + for (const action of [ + 'resume', + 'follow-up', + 'fork', + 'interrupt', + 'compact', + 'archive', + 'close', + ]) { + expect( + getApiPermissionRequirement(`/api/v1/agents/task_1/conversation/${action}`, { + method: 'POST', + }).permissions + ).toEqual(['agent:write']); + } + }); + it('requires workflow execution for Codex review diff posts', () => { expect( getApiPermissionRequirement('/api/diff/task_1/codex-review', { method: 'POST' }).permissions diff --git a/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadArchiveResponse.json b/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadArchiveResponse.json new file mode 100644 index 00000000..6927d39e --- /dev/null +++ b/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadArchiveResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadArchiveResponse", + "type": "object" +} diff --git a/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadCompactStartResponse.json b/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadCompactStartResponse.json new file mode 100644 index 00000000..c6b20db1 --- /dev/null +++ b/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadCompactStartResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadCompactStartResponse", + "type": "object" +} diff --git a/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadForkResponse.json b/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadForkResponse.json new file mode 100644 index 00000000..18de8b7f --- /dev/null +++ b/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadForkResponse.json @@ -0,0 +1,2030 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadForkResponse", + "type": "object", + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "properties": { + "activePermissionProfile": { + "description": "Named or implicit built-in profile that produced the active permissions, when known.", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "description": "Reviewer currently used for approval requests on this thread.", + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ] + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "instructionSources": { + "description": "Environment-native paths to instruction source files currently loaded for this thread.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "multiAgentMode": { + "description": "@deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior.", + "default": "explicitRequestOnly", + "allOf": [ + { + "$ref": "#/definitions/MultiAgentMode" + } + ] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "runtimeWorkspaceRoots": { + "description": "Thread-scoped runtime workspace roots used to materialize `:workspace_roots`.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "sandbox": { + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance.", + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ] + }, + "serviceTier": { + "type": ["string", "null"] + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "type": "object", + "required": ["id"], + "properties": { + "extends": { + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", + "default": null, + "type": ["string", "null"] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + } + }, + "AgentPath": { + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "type": "string", + "enum": ["user", "auto_review", "guardian_subagent"] + }, + "AskForApproval": { + "oneOf": [ + { + "type": "string", + "enum": ["untrusted", "on-request", "never"] + }, + { + "type": "object", + "required": ["granular"], + "properties": { + "granular": { + "type": "object", + "required": ["mcp_elicitations", "rules", "sandbox_approval"], + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + } + } + }, + "additionalProperties": false, + "title": "GranularAskForApproval" + } + ] + }, + "ByteRange": { + "type": "object", + "required": ["end", "start"], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "type": "string", + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ] + }, + { + "type": "object", + "required": ["httpConnectionFailed"], + "properties": { + "httpConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": ["integer", "null"], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "HttpConnectionFailedCodexErrorInfo" + }, + { + "description": "Failed to connect to the response SSE stream.", + "type": "object", + "required": ["responseStreamConnectionFailed"], + "properties": { + "responseStreamConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": ["integer", "null"], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamConnectionFailedCodexErrorInfo" + }, + { + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "type": "object", + "required": ["responseStreamDisconnected"], + "properties": { + "responseStreamDisconnected": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": ["integer", "null"], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamDisconnectedCodexErrorInfo" + }, + { + "description": "Reached the retry limit for responses.", + "type": "object", + "required": ["responseTooManyFailedAttempts"], + "properties": { + "responseTooManyFailedAttempts": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": ["integer", "null"], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo" + }, + { + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "type": "object", + "required": ["activeTurnNotSteerable"], + "properties": { + "activeTurnNotSteerable": { + "type": "object", + "required": ["turnKind"], + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + } + } + }, + "additionalProperties": false, + "title": "ActiveTurnNotSteerableCodexErrorInfo" + } + ] + }, + "CollabAgentState": { + "type": "object", + "required": ["status"], + "properties": { + "message": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": ["spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent"] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed"] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": ["command", "name", "path", "type"], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": ["read"], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": ["command", "type"], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["listFiles"], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": ["command", "type"], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["search"], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": ["command", "type"], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["unknown"], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": ["agent", "userShell", "unifiedExecStartup", "unifiedExecInteraction"] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed", "declined"] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": ["text", "type"], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["inputText"], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": ["imageUrl", "type"], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["inputImage"], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": ["audioUrl", "type"], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["inputAudio"], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed"] + }, + "FileUpdateChange": { + "type": "object", + "required": ["diff", "kind", "path"], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "GitInfo": { + "type": "object", + "properties": { + "branch": { + "type": ["string", "null"] + }, + "originUrl": { + "type": ["string", "null"] + }, + "sha": { + "type": ["string", "null"] + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": ["hookRunId", "text"], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": ["auto", "low", "high", "original"] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": ["connectorId"], + "properties": { + "actionName": { + "type": ["string", "null"] + }, + "appName": { + "type": ["string", "null"] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": ["string", "null"] + }, + "resourceUri": { + "type": ["string", "null"] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": ["content"], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed"] + }, + "MemoryCitation": { + "type": "object", + "required": ["entries", "threadIds"], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": ["lineEnd", "lineStart", "note", "path"], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": ["commentary"] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": ["final_answer"] + } + ] + }, + "MultiAgentMode": { + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "type": "string", + "enum": ["explicitRequestOnly", "proactive"] + }, + { + "type": "object", + "required": ["custom"], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomMultiAgentMode" + } + ] + }, + "NetworkAccess": { + "type": "string", + "enum": ["restricted", "enabled"] + }, + "NonSteerableTurnKind": { + "type": "string", + "enum": ["review", "compact"] + }, + "PatchApplyStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed", "declined"] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["add"], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["delete"], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "move_path": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["update"], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SandboxPolicy": { + "oneOf": [ + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["dangerFullAccess"], + "title": "DangerFullAccessSandboxPolicyType" + } + }, + "title": "DangerFullAccessSandboxPolicy" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": ["readOnly"], + "title": "ReadOnlySandboxPolicyType" + } + }, + "title": "ReadOnlySandboxPolicy" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "networkAccess": { + "default": "restricted", + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ] + }, + "type": { + "type": "string", + "enum": ["externalSandbox"], + "title": "ExternalSandboxSandboxPolicyType" + } + }, + "title": "ExternalSandboxSandboxPolicy" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": ["workspaceWrite"], + "title": "WorkspaceWriteSandboxPolicyType" + }, + "writableRoots": { + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + } + }, + "title": "WorkspaceWriteSandboxPolicy" + } + ] + }, + "SessionSource": { + "oneOf": [ + { + "type": "string", + "enum": ["cli", "vscode", "exec", "appServer", "unknown"] + }, + { + "type": "object", + "required": ["custom"], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomSessionSource" + }, + { + "type": "object", + "required": ["subAgent"], + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "additionalProperties": false, + "title": "SubAgentSessionSource" + } + ] + }, + "SubAgentActivityKind": { + "type": "string", + "enum": ["started", "interacted", "interrupted"] + }, + "SubAgentSource": { + "oneOf": [ + { + "type": "string", + "enum": ["review", "compact", "memory_consolidation"] + }, + { + "type": "object", + "required": ["thread_spawn"], + "properties": { + "thread_spawn": { + "type": "object", + "required": ["depth", "parent_thread_id"], + "properties": { + "agent_nickname": { + "default": null, + "type": ["string", "null"] + }, + "agent_path": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ] + }, + "agent_role": { + "default": null, + "type": ["string", "null"] + }, + "depth": { + "type": "integer", + "format": "int32" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + } + } + }, + "additionalProperties": false, + "title": "ThreadSpawnSubAgentSource" + }, + { + "type": "object", + "required": ["other"], + "properties": { + "other": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "OtherSubAgentSource" + } + ] + }, + "TextElement": { + "type": "object", + "required": ["byteRange"], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": ["string", "null"] + } + } + }, + "Thread": { + "type": "object", + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "canAcceptDirectInput": { + "description": "Whether the app server accepts direct turn input for this loaded thread. `None` means the capability is unavailable, such as for an unloaded stored thread.", + "type": ["boolean", "null"] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "type": "integer", + "format": "int64" + }, + "cwd": { + "description": "Working directory captured for the thread.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "extra": { + "description": "Optional implementation-specific thread data.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadExtra" + }, + { + "type": "null" + } + ] + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": ["string", "null"] + }, + "gitInfo": { + "description": "Optional Git metadata captured when the thread was created.", + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ] + }, + "historyMode": { + "description": "Persisted thread history contract selected when this thread was created.", + "default": "legacy", + "allOf": [ + { + "$ref": "#/definitions/ThreadHistoryMode" + } + ] + }, + "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": ["string", "null"] + }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": ["string", "null"] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": ["string", "null"] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "type": ["integer", "null"], + "format": "int64" + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).", + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ] + }, + "status": { + "description": "Current runtime status for the thread.", + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ] + }, + "threadSource": { + "description": "Optional analytics source classification for this thread.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ] + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "type": "array", + "items": { + "$ref": "#/definitions/Turn" + } + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "type": "integer", + "format": "int64" + } + } + }, + "ThreadActiveFlag": { + "type": "string", + "enum": ["waitingOnApproval", "waitingOnUserInput"] + }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, + "ThreadHistoryMode": { + "type": "string", + "enum": ["legacy", "paginated"] + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": ["content", "id", "type"], + "properties": { + "clientId": { + "type": ["string", "null"] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["userMessage"], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": ["fragments", "id", "type"], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["hookPrompt"], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": ["id", "text", "type"], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["agentMessage"], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": ["id", "text", "type"], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["plan"], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": ["id", "type"], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": ["reasoning"], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": ["command", "commandActions", "cwd", "id", "status", "type"], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": ["string", "null"] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": ["integer", "null"], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": ["integer", "null"], + "format": "int32" + }, + "id": { + "type": "string" + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": ["string", "null"] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": ["commandExecution"], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": ["changes", "id", "status", "type"], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": ["fileChange"], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": ["arguments", "id", "server", "status", "tool", "type"], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": ["integer", "null"], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": ["string", "null"] + }, + "pluginId": { + "type": ["string", "null"] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcpToolCall"], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": ["arguments", "id", "status", "tool", "type"], + "properties": { + "arguments": true, + "contentItems": { + "type": ["array", "null"], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": ["integer", "null"], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": ["boolean", "null"] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["dynamicToolCall"], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": ["string", "null"] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": ["string", "null"] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": ["collabAgentToolCall"], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": ["agentPath", "agentThreadId", "id", "kind", "type"], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": ["subAgentActivity"], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": ["id", "query", "type"], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": ["array", "null"], + "items": true + }, + "type": { + "type": "string", + "enum": ["webSearch"], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": ["id", "path", "type"], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": ["imageView"], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": ["durationMs", "id", "type"], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["sleep"], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": ["id", "result", "status", "type"], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": ["string", "null"] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["imageGeneration"], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": ["id", "review", "type"], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["enteredReviewMode"], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": ["id", "review", "type"], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["exitedReviewMode"], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": ["id", "type"], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["contextCompaction"], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "ThreadSource": { + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["notLoaded"], + "title": "NotLoadedThreadStatusType" + } + }, + "title": "NotLoadedThreadStatus" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["idle"], + "title": "IdleThreadStatusType" + } + }, + "title": "IdleThreadStatus" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["systemError"], + "title": "SystemErrorThreadStatusType" + } + }, + "title": "SystemErrorThreadStatus" + }, + { + "type": "object", + "required": ["activeFlags", "type"], + "properties": { + "activeFlags": { + "type": "array", + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + } + }, + "type": { + "type": "string", + "enum": ["active"], + "title": "ActiveThreadStatusType" + } + }, + "title": "ActiveThreadStatus" + } + ] + }, + "Turn": { + "type": "object", + "required": ["id", "items", "status"], + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "type": ["integer", "null"], + "format": "int64" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "type": ["integer", "null"], + "format": "int64" + }, + "error": { + "description": "Only populated when the Turn's status is failed.", + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ] + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "type": "array", + "items": { + "$ref": "#/definitions/ThreadItem" + } + }, + "itemsView": { + "description": "Describes how much of `items` has been loaded for this turn.", + "default": "full", + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ] + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "type": ["integer", "null"], + "format": "int64" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + } + }, + "TurnError": { + "type": "object", + "required": ["message"], + "properties": { + "additionalDetails": { + "default": null, + "type": ["string", "null"] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + } + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "type": "string", + "enum": ["notLoaded"] + }, + { + "description": "`items` contains only a display summary for this turn.", + "type": "string", + "enum": ["summary"] + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "type": "string", + "enum": ["full"] + } + ] + }, + "TurnStatus": { + "type": "string", + "enum": ["completed", "interrupted", "failed", "inProgress"] + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": ["text", "type"], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": ["text"], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": ["type", "url"], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": ["image"], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": ["path", "type"], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["localImage"], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": ["type", "url"], + "properties": { + "type": { + "type": "string", + "enum": ["audio"], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": ["path", "type"], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["localAudio"], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": ["name", "path", "type"], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["skill"], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": ["name", "path", "type"], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mention"], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": ["type"], + "properties": { + "queries": { + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["search"], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["openPage"], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": ["string", "null"] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "pattern": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["findInPage"], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": ["string", "null"] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["other"], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} diff --git a/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadResumeResponse.json b/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadResumeResponse.json new file mode 100644 index 00000000..3e5659c9 --- /dev/null +++ b/server/src/contracts/codex-app-server-v0.145.0/v2/ThreadResumeResponse.json @@ -0,0 +1,2070 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadResumeResponse", + "type": "object", + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "properties": { + "activePermissionProfile": { + "description": "Named or implicit built-in profile that produced the active permissions, when known.", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "description": "Reviewer currently used for approval requests on this thread.", + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ] + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "initialTurnsPage": { + "description": "`thread/turns/list` page returned when requested by `initialTurnsPage`.", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/TurnsPage" + }, + { + "type": "null" + } + ] + }, + "instructionSources": { + "description": "Environment-native paths to instruction source files currently loaded for this thread.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + }, + "itemsBackwardsCursor": { + "description": "Opaque head cursor for hydrating paginated items backwards.\n\nPass this as `cursor` to `thread/items/list` with `sortDirection: \"desc\"`. The first page includes the cursor's head item.", + "default": null, + "type": ["string", "null"] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "multiAgentMode": { + "description": "@deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior.", + "default": "explicitRequestOnly", + "allOf": [ + { + "$ref": "#/definitions/MultiAgentMode" + } + ] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "runtimeWorkspaceRoots": { + "description": "Thread-scoped runtime workspace roots used to materialize `:workspace_roots`.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "sandbox": { + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance.", + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ] + }, + "serviceTier": { + "type": ["string", "null"] + }, + "thread": { + "$ref": "#/definitions/Thread" + }, + "turnsBackwardsCursor": { + "description": "Opaque head cursor for hydrating paginated turns backwards.\n\nPass this as `cursor` to `thread/turns/list` with `sortDirection: \"desc\"`. The first page includes the cursor's head turn.", + "default": null, + "type": ["string", "null"] + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "type": "object", + "required": ["id"], + "properties": { + "extends": { + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", + "default": null, + "type": ["string", "null"] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + } + }, + "AgentPath": { + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "type": "string", + "enum": ["user", "auto_review", "guardian_subagent"] + }, + "AskForApproval": { + "oneOf": [ + { + "type": "string", + "enum": ["untrusted", "on-request", "never"] + }, + { + "type": "object", + "required": ["granular"], + "properties": { + "granular": { + "type": "object", + "required": ["mcp_elicitations", "rules", "sandbox_approval"], + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + } + } + }, + "additionalProperties": false, + "title": "GranularAskForApproval" + } + ] + }, + "ByteRange": { + "type": "object", + "required": ["end", "start"], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "type": "string", + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ] + }, + { + "type": "object", + "required": ["httpConnectionFailed"], + "properties": { + "httpConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": ["integer", "null"], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "HttpConnectionFailedCodexErrorInfo" + }, + { + "description": "Failed to connect to the response SSE stream.", + "type": "object", + "required": ["responseStreamConnectionFailed"], + "properties": { + "responseStreamConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": ["integer", "null"], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamConnectionFailedCodexErrorInfo" + }, + { + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "type": "object", + "required": ["responseStreamDisconnected"], + "properties": { + "responseStreamDisconnected": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": ["integer", "null"], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamDisconnectedCodexErrorInfo" + }, + { + "description": "Reached the retry limit for responses.", + "type": "object", + "required": ["responseTooManyFailedAttempts"], + "properties": { + "responseTooManyFailedAttempts": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": ["integer", "null"], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo" + }, + { + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "type": "object", + "required": ["activeTurnNotSteerable"], + "properties": { + "activeTurnNotSteerable": { + "type": "object", + "required": ["turnKind"], + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + } + } + }, + "additionalProperties": false, + "title": "ActiveTurnNotSteerableCodexErrorInfo" + } + ] + }, + "CollabAgentState": { + "type": "object", + "required": ["status"], + "properties": { + "message": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": ["spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent"] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed"] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": ["command", "name", "path", "type"], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": ["read"], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": ["command", "type"], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["listFiles"], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": ["command", "type"], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["search"], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": ["command", "type"], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["unknown"], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": ["agent", "userShell", "unifiedExecStartup", "unifiedExecInteraction"] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed", "declined"] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": ["text", "type"], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["inputText"], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": ["imageUrl", "type"], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["inputImage"], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": ["audioUrl", "type"], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["inputAudio"], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed"] + }, + "FileUpdateChange": { + "type": "object", + "required": ["diff", "kind", "path"], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "GitInfo": { + "type": "object", + "properties": { + "branch": { + "type": ["string", "null"] + }, + "originUrl": { + "type": ["string", "null"] + }, + "sha": { + "type": ["string", "null"] + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": ["hookRunId", "text"], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": ["auto", "low", "high", "original"] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": ["connectorId"], + "properties": { + "actionName": { + "type": ["string", "null"] + }, + "appName": { + "type": ["string", "null"] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": ["string", "null"] + }, + "resourceUri": { + "type": ["string", "null"] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": ["content"], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed"] + }, + "MemoryCitation": { + "type": "object", + "required": ["entries", "threadIds"], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": ["lineEnd", "lineStart", "note", "path"], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": ["commentary"] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": ["final_answer"] + } + ] + }, + "MultiAgentMode": { + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "type": "string", + "enum": ["explicitRequestOnly", "proactive"] + }, + { + "type": "object", + "required": ["custom"], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomMultiAgentMode" + } + ] + }, + "NetworkAccess": { + "type": "string", + "enum": ["restricted", "enabled"] + }, + "NonSteerableTurnKind": { + "type": "string", + "enum": ["review", "compact"] + }, + "PatchApplyStatus": { + "type": "string", + "enum": ["inProgress", "completed", "failed", "declined"] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["add"], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["delete"], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "move_path": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["update"], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SandboxPolicy": { + "oneOf": [ + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["dangerFullAccess"], + "title": "DangerFullAccessSandboxPolicyType" + } + }, + "title": "DangerFullAccessSandboxPolicy" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": ["readOnly"], + "title": "ReadOnlySandboxPolicyType" + } + }, + "title": "ReadOnlySandboxPolicy" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "networkAccess": { + "default": "restricted", + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ] + }, + "type": { + "type": "string", + "enum": ["externalSandbox"], + "title": "ExternalSandboxSandboxPolicyType" + } + }, + "title": "ExternalSandboxSandboxPolicy" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": ["workspaceWrite"], + "title": "WorkspaceWriteSandboxPolicyType" + }, + "writableRoots": { + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + } + }, + "title": "WorkspaceWriteSandboxPolicy" + } + ] + }, + "SessionSource": { + "oneOf": [ + { + "type": "string", + "enum": ["cli", "vscode", "exec", "appServer", "unknown"] + }, + { + "type": "object", + "required": ["custom"], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomSessionSource" + }, + { + "type": "object", + "required": ["subAgent"], + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "additionalProperties": false, + "title": "SubAgentSessionSource" + } + ] + }, + "SubAgentActivityKind": { + "type": "string", + "enum": ["started", "interacted", "interrupted"] + }, + "SubAgentSource": { + "oneOf": [ + { + "type": "string", + "enum": ["review", "compact", "memory_consolidation"] + }, + { + "type": "object", + "required": ["thread_spawn"], + "properties": { + "thread_spawn": { + "type": "object", + "required": ["depth", "parent_thread_id"], + "properties": { + "agent_nickname": { + "default": null, + "type": ["string", "null"] + }, + "agent_path": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ] + }, + "agent_role": { + "default": null, + "type": ["string", "null"] + }, + "depth": { + "type": "integer", + "format": "int32" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + } + } + }, + "additionalProperties": false, + "title": "ThreadSpawnSubAgentSource" + }, + { + "type": "object", + "required": ["other"], + "properties": { + "other": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "OtherSubAgentSource" + } + ] + }, + "TextElement": { + "type": "object", + "required": ["byteRange"], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": ["string", "null"] + } + } + }, + "Thread": { + "type": "object", + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "canAcceptDirectInput": { + "description": "Whether the app server accepts direct turn input for this loaded thread. `None` means the capability is unavailable, such as for an unloaded stored thread.", + "type": ["boolean", "null"] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "type": "integer", + "format": "int64" + }, + "cwd": { + "description": "Working directory captured for the thread.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "extra": { + "description": "Optional implementation-specific thread data.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadExtra" + }, + { + "type": "null" + } + ] + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": ["string", "null"] + }, + "gitInfo": { + "description": "Optional Git metadata captured when the thread was created.", + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ] + }, + "historyMode": { + "description": "Persisted thread history contract selected when this thread was created.", + "default": "legacy", + "allOf": [ + { + "$ref": "#/definitions/ThreadHistoryMode" + } + ] + }, + "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": ["string", "null"] + }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": ["string", "null"] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": ["string", "null"] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "type": ["integer", "null"], + "format": "int64" + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).", + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ] + }, + "status": { + "description": "Current runtime status for the thread.", + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ] + }, + "threadSource": { + "description": "Optional analytics source classification for this thread.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ] + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "type": "array", + "items": { + "$ref": "#/definitions/Turn" + } + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "type": "integer", + "format": "int64" + } + } + }, + "ThreadActiveFlag": { + "type": "string", + "enum": ["waitingOnApproval", "waitingOnUserInput"] + }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, + "ThreadHistoryMode": { + "type": "string", + "enum": ["legacy", "paginated"] + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": ["content", "id", "type"], + "properties": { + "clientId": { + "type": ["string", "null"] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["userMessage"], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": ["fragments", "id", "type"], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["hookPrompt"], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": ["id", "text", "type"], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["agentMessage"], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": ["id", "text", "type"], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["plan"], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": ["id", "type"], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": ["reasoning"], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": ["command", "commandActions", "cwd", "id", "status", "type"], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": ["string", "null"] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": ["integer", "null"], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": ["integer", "null"], + "format": "int32" + }, + "id": { + "type": "string" + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": ["string", "null"] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": ["commandExecution"], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": ["changes", "id", "status", "type"], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": ["fileChange"], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": ["arguments", "id", "server", "status", "tool", "type"], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": ["integer", "null"], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": ["string", "null"] + }, + "pluginId": { + "type": ["string", "null"] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcpToolCall"], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": ["arguments", "id", "status", "tool", "type"], + "properties": { + "arguments": true, + "contentItems": { + "type": ["array", "null"], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": ["integer", "null"], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": ["boolean", "null"] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["dynamicToolCall"], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": ["string", "null"] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": ["string", "null"] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": ["collabAgentToolCall"], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": ["agentPath", "agentThreadId", "id", "kind", "type"], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": ["subAgentActivity"], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": ["id", "query", "type"], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": ["array", "null"], + "items": true + }, + "type": { + "type": "string", + "enum": ["webSearch"], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": ["id", "path", "type"], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": ["imageView"], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": ["durationMs", "id", "type"], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["sleep"], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": ["id", "result", "status", "type"], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": ["string", "null"] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["imageGeneration"], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": ["id", "review", "type"], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["enteredReviewMode"], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": ["id", "review", "type"], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["exitedReviewMode"], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": ["id", "type"], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["contextCompaction"], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "ThreadSource": { + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["notLoaded"], + "title": "NotLoadedThreadStatusType" + } + }, + "title": "NotLoadedThreadStatus" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["idle"], + "title": "IdleThreadStatusType" + } + }, + "title": "IdleThreadStatus" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["systemError"], + "title": "SystemErrorThreadStatusType" + } + }, + "title": "SystemErrorThreadStatus" + }, + { + "type": "object", + "required": ["activeFlags", "type"], + "properties": { + "activeFlags": { + "type": "array", + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + } + }, + "type": { + "type": "string", + "enum": ["active"], + "title": "ActiveThreadStatusType" + } + }, + "title": "ActiveThreadStatus" + } + ] + }, + "Turn": { + "type": "object", + "required": ["id", "items", "status"], + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "type": ["integer", "null"], + "format": "int64" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "type": ["integer", "null"], + "format": "int64" + }, + "error": { + "description": "Only populated when the Turn's status is failed.", + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ] + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "type": "array", + "items": { + "$ref": "#/definitions/ThreadItem" + } + }, + "itemsView": { + "description": "Describes how much of `items` has been loaded for this turn.", + "default": "full", + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ] + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "type": ["integer", "null"], + "format": "int64" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + } + }, + "TurnError": { + "type": "object", + "required": ["message"], + "properties": { + "additionalDetails": { + "default": null, + "type": ["string", "null"] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + } + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "type": "string", + "enum": ["notLoaded"] + }, + { + "description": "`items` contains only a display summary for this turn.", + "type": "string", + "enum": ["summary"] + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "type": "string", + "enum": ["full"] + } + ] + }, + "TurnStatus": { + "type": "string", + "enum": ["completed", "interrupted", "failed", "inProgress"] + }, + "TurnsPage": { + "type": "object", + "required": ["data"], + "properties": { + "backwardsCursor": { + "type": ["string", "null"] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/Turn" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": ["text", "type"], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": ["text"], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": ["type", "url"], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": ["image"], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": ["path", "type"], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["localImage"], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": ["type", "url"], + "properties": { + "type": { + "type": "string", + "enum": ["audio"], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": ["path", "type"], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["localAudio"], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": ["name", "path", "type"], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["skill"], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": ["name", "path", "type"], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mention"], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": ["type"], + "properties": { + "queries": { + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["search"], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["openPage"], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": ["string", "null"] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "pattern": { + "type": ["string", "null"] + }, + "type": { + "type": "string", + "enum": ["findInPage"], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": ["string", "null"] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["other"], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} diff --git a/server/src/contracts/codex-app-server-v0.145.0/v2/TurnSteerResponse.json b/server/src/contracts/codex-app-server-v0.145.0/v2/TurnSteerResponse.json new file mode 100644 index 00000000..9f966930 --- /dev/null +++ b/server/src/contracts/codex-app-server-v0.145.0/v2/TurnSteerResponse.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnSteerResponse", + "type": "object", + "required": ["turnId"], + "properties": { + "turnId": { + "type": "string" + } + } +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 377fceb8..6fc92ade 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,6 +1,10 @@ import { Router, type Router as RouterType } from 'express'; import { z } from 'zod'; -import { AgentReadinessError, clawdbotAgentService } from '../services/clawdbot-agent-service.js'; +import { + AgentReadinessError, + clawdbotAgentService, + type AgentStartOptions, +} from '../services/clawdbot-agent-service.js'; import { getTelemetryService } from '../services/telemetry-service.js'; import { getTaskService } from '../services/task-service.js'; import type { @@ -139,10 +143,28 @@ const sendAgentMessageSchema = z.object({ actor: z.string().trim().min(1).max(120).optional(), }); +const conversationSteerSchema = sendAgentMessageSchema.omit({ actor: true }).strict(); + const runControlSchema = z.object({ attemptId: z.string().trim().min(1).max(120), }); +const conversationTurnSchema = z + .object({ + sourceAttemptId: z.string().trim().min(1).max(120), + message: z.string().trim().min(1).max(20_000), + forkTurnId: z.string().trim().min(1).max(240).optional(), + profileId: AgentTypeSchema.optional(), + overrideReason: z.string().trim().min(8).max(1000).optional(), + sandboxPresetId: z.string().trim().min(1).max(80).optional(), + budget: AgentBudgetPolicySchema.optional(), + requiredRuntimeCapabilities: z.array(ProviderRuntimeCapabilityIdSchema).max(64).optional(), + commitPolicy: TaskCommitPolicySchema.optional(), + }) + .strict(); + +const conversationControlSchema = runControlSchema.strict(); + const reportTokensSchema = z.object({ attemptId: z.string().trim().min(1).max(120), inputTokens: z.number({ message: 'inputTokens is required' }).int().nonnegative(), @@ -324,12 +346,10 @@ router.post( asyncHandler(async (req, res) => { let message: string; let attemptId: string; - let actorOverride: string | undefined; try { const parsed = sendAgentMessageSchema.parse(req.body); message = parsed.message; attemptId = parsed.attemptId; - actorOverride = parsed.actor; } catch (err) { if (err instanceof z.ZodError) { throw new ValidationError('Validation failed', err.issues); @@ -337,17 +357,8 @@ router.post( throw err; } - const auth = (req as AuthenticatedRequest).auth; - const actor = - actorOverride || - auth?.userId || - auth?.tokenName || - auth?.keyName || - auth?.clientId || - auth?.role || - 'operator'; const delivery = await clawdbotAgentService.sendMessage(req.params.taskId as string, message, { - actor, + actor: requestActor(req), source: 'agent-route', expectedAttemptId: attemptId, }); @@ -355,6 +366,123 @@ router.post( }) ); +// POST /api/agents/:taskId/conversation/resume - Continue a terminal provider conversation. +router.post( + '/:taskId/conversation/resume', + requireLocalAgentCapability, + asyncHandler(async (req, res) => { + const body = parseConversationTurn(req.body); + if (body.forkTurnId) { + throw new ValidationError('Resume cannot specify forkTurnId'); + } + const status = await clawdbotAgentService.resumeConversation( + req.params.taskId as string, + body.sourceAttemptId, + body.message, + conversationStartOptions(body) + ); + res.status(201).json(status); + }) +); + +// POST /api/agents/:taskId/conversation/follow-up - Start a native follow-up turn. +router.post( + '/:taskId/conversation/follow-up', + requireLocalAgentCapability, + asyncHandler(async (req, res) => { + const body = parseConversationTurn(req.body); + if (body.forkTurnId) { + throw new ValidationError('Follow-up cannot specify forkTurnId'); + } + const status = await clawdbotAgentService.followUpConversation( + req.params.taskId as string, + body.sourceAttemptId, + body.message, + conversationStartOptions(body) + ); + res.status(201).json(status); + }) +); + +// POST /api/agents/:taskId/conversation/fork - Fork native history into this task run. +router.post( + '/:taskId/conversation/fork', + requireLocalAgentCapability, + asyncHandler(async (req, res) => { + const body = parseConversationTurn(req.body); + const status = await clawdbotAgentService.forkConversation( + req.params.taskId as string, + body.sourceAttemptId, + body.message, + body.forkTurnId, + conversationStartOptions(body) + ); + res.status(201).json(status); + }) +); + +// POST /api/agents/:taskId/conversation/steer - Steer the exact active provider turn. +router.post( + '/:taskId/conversation/steer', + requireLocalAgentCapability, + asyncHandler(async (req, res) => { + const parsed = parseConversationSteer(req.body); + res.json( + await clawdbotAgentService.sendMessage(req.params.taskId as string, parsed.message, { + actor: requestActor(req), + source: 'conversation-route', + expectedAttemptId: parsed.attemptId, + }) + ); + }) +); + +// POST /api/agents/:taskId/conversation/interrupt - Interrupt the exact active attempt. +router.post( + '/:taskId/conversation/interrupt', + requireLocalAgentCapability, + asyncHandler(async (req, res) => { + const body = parseConversationControl(req.body); + res.json( + await clawdbotAgentService.interruptConversation( + req.params.taskId as string, + body.attemptId, + requestActor(req) + ) + ); + }) +); + +for (const action of ['compact', 'archive', 'close'] as const) { + router.post( + `/:taskId/conversation/${action}`, + requireLocalAgentCapability, + asyncHandler(async (req, res) => { + const body = parseConversationControl(req.body); + const actor = requestActor(req); + const result = + action === 'compact' + ? await clawdbotAgentService.compactConversation( + req.params.taskId as string, + body.attemptId, + actor + ) + : action === 'archive' + ? await clawdbotAgentService.archiveConversation( + req.params.taskId as string, + body.attemptId, + actor + ) + : await clawdbotAgentService.closeConversation( + req.params.taskId as string, + body.attemptId, + actor + ); + res.json(result); + }) + ); +} + // GET /api/agents/:taskId/status - Get agent status router.get( '/:taskId/status', @@ -496,3 +624,57 @@ router.post( // Export service for WebSocket use export { router as agentRoutes, clawdbotAgentService as agentService }; + +function parseConversationTurn(input: unknown): z.infer { + try { + return conversationTurnSchema.parse(input); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError('Validation failed', error.issues); + } + throw error; + } +} + +function parseConversationControl(input: unknown): z.infer { + try { + return conversationControlSchema.parse(input); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError('Validation failed', error.issues); + } + throw error; + } +} + +function parseConversationSteer(input: unknown): z.infer { + try { + return conversationSteerSchema.parse(input); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError('Validation failed', error.issues); + } + throw error; + } +} + +function conversationStartOptions( + body: z.infer +): Omit { + return { + profileId: body.profileId, + overrideReason: body.overrideReason, + sandboxPresetId: body.sandboxPresetId, + budget: body.budget, + requiredRuntimeCapabilities: body.requiredRuntimeCapabilities as + ProviderRuntimeCapabilityId[] | undefined, + commitPolicy: body.commitPolicy as TaskCommitPolicy | undefined, + }; +} + +function requestActor(req: AuthenticatedRequest): string { + const auth = req.auth; + return ( + auth?.userId || auth?.tokenName || auth?.keyName || auth?.clientId || auth?.role || 'operator' + ); +} diff --git a/server/src/services/claude-code-adapter.ts b/server/src/services/claude-code-adapter.ts index be8186df..f1fd4dc1 100644 --- a/server/src/services/claude-code-adapter.ts +++ b/server/src/services/claude-code-adapter.ts @@ -108,6 +108,8 @@ export interface ClaudeCodeLaunchInput { prompt: string; model?: string; extraArgs?: string[]; + resumeSessionId?: string; + forkSession?: boolean; sandboxMode: SandboxPolicyDryRunResult['effective']['sandboxMode']; networkAccessEnabled: boolean; maxBudgetUsd?: number; @@ -239,10 +241,25 @@ export function buildClaudeCodeArgs(input: ClaudeCodeLaunchInput): string[] { args.push('--max-budget-usd', String(input.maxBudgetUsd)); } if (input.model?.trim()) args.push('--model', input.model.trim()); + if (input.resumeSessionId) { + const sessionId = validatedSessionId(input.resumeSessionId); + args.push('--resume', sessionId); + if (input.forkSession) args.push('--fork-session'); + } else if (input.forkSession) { + throw new Error('Claude Code session fork requires an exact source session ID.'); + } args.push(input.prompt); return args; } +function validatedSessionId(value: string): string { + const sessionId = value.trim(); + if (!sessionId || sessionId.startsWith('-') || Buffer.byteLength(sessionId, 'utf8') > 240) { + throw new Error('Claude Code session ID is invalid.'); + } + return sessionId; +} + function normalizeExtraArgs(args: string[]): string[] { const normalized: string[] = []; for (let index = 0; index < args.length; index += 1) { diff --git a/server/src/services/clawdbot-agent-service.ts b/server/src/services/clawdbot-agent-service.ts index 7590f27d..56cfcc69 100644 --- a/server/src/services/clawdbot-agent-service.ts +++ b/server/src/services/clawdbot-agent-service.ts @@ -51,6 +51,7 @@ import { import type { ThreadEvent } from '@openai/codex-sdk'; import { evaluateTaskReadiness, + CONVERSATION_LIFECYCLE_SCHEMA_VERSION, EXECUTABLE_AGENT_PROVIDERS, RUN_LAUNCH_MANIFEST_SCHEMA_VERSION, } from '@veritas-kanban/shared'; @@ -102,6 +103,9 @@ import type { RunSupervisorRecord, RunSupervisorRecoveryRecord, RunSupervisorRecoveryOperation, + ConversationLaunchRequest, + ConversationLifecycleRecord, + ConversationLifecycleResult, } from '@veritas-kanban/shared'; import { createLogger } from '../lib/logger.js'; import { ConflictError } from '../middleware/error-handler.js'; @@ -179,6 +183,10 @@ import { type RunApprovalBrokerService, } from './run-approval-broker-service.js'; import { getRunSupervisorService, type RunSupervisorService } from './run-supervisor-service.js'; +import { + ConversationLifecycleService, + type ConversationSource, +} from './conversation-lifecycle-service.js'; const log = createLogger('clawdbot-agent-service'); const TRACE_SECRET_PATTERNS: Array<[RegExp, string]> = [ @@ -205,6 +213,7 @@ export interface AgentProviderStartContext { attempt: TaskAttempt; sandboxPolicy?: SandboxPolicyDryRunResult; runLaunchManifest: RunLaunchManifest; + conversation: ConversationLifecycleRecord; } export interface AgentProviderStopContext { @@ -242,6 +251,7 @@ export interface AgentStatus { runLaunchManifest: RunLaunchManifest; runLaunchParentAttemptId?: string; runLaunchManifestDrift?: RunLaunchManifestDriftResult; + conversation: ConversationLifecycleRecord; controls: ProviderRuntimeControlSet; } @@ -259,6 +269,7 @@ export interface AgentStartOptions { requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[]; commitPolicy?: TaskCommitPolicy; parentAttemptId?: string; + conversation?: ConversationLaunchRequest; } export interface AgentMessageOptions { @@ -273,10 +284,7 @@ export interface AgentCompletionProvenance { terminalSource?: TaskTerminalSource; } -export interface AgentMessageDelivery { - delivered: boolean; - note: string; -} +export type AgentMessageDelivery = ConversationLifecycleResult; export interface CredentialLeaseLifecycle { revokeRun(request: CredentialRunRevocationRequest): Promise; @@ -311,6 +319,7 @@ interface PendingAgent { runLaunchManifestTraceId: string; runLaunchParentAttemptId?: string; runLaunchManifestDrift?: RunLaunchManifestDriftResult; + conversation: ConversationLifecycleRecord; supervisorId?: string; recoveredControl?: boolean; threadId?: string; @@ -318,6 +327,9 @@ interface PendingAgent { process?: ChildProcessWithoutNullStreams; codexAppServerControl?: { interrupt(): Promise; + steer(message: string): Promise; + compact(): Promise; + archive(): Promise; close(): void; }; /** Durable session key returned by OpenClaw sessions_spawn (openclaw provider only) */ @@ -408,6 +420,7 @@ export class ClawdbotAgentService { private runEvents: RunEventJournalService; private approvalBroker: RunApprovalBrokerService; private runSupervisor: RunSupervisorService; + private conversationLifecycle: ConversationLifecycleService; private logsDir: string; constructor( @@ -420,7 +433,8 @@ export class ClawdbotAgentService { worktrees?: Pick, runEvents: RunEventJournalService = getRunEventJournalService(), approvalBroker: RunApprovalBrokerService = getRunApprovalBrokerService(), - runSupervisor: RunSupervisorService = getRunSupervisorService() + runSupervisor: RunSupervisorService = getRunSupervisorService(), + conversationLifecycle = new ConversationLifecycleService() ) { this.configService = new ConfigService(); this.taskService = new TaskService(); @@ -440,6 +454,7 @@ export class ClawdbotAgentService { this.runEvents = runEvents; this.approvalBroker = approvalBroker; this.runSupervisor = runSupervisor; + this.conversationLifecycle = conversationLifecycle; this.logsDir = getLogsDir(); this.ensureLogsDir(); } @@ -698,6 +713,7 @@ export class ClawdbotAgentService { : supervisor.control.kind === 'local-process' ? supervisor.control.sessionId : undefined; + const recoveredConversation = this.conversationLifecycle.recover(attempt, sessionId); const pending: PendingAgent = { taskId: task.id, attemptId: attempt.id, @@ -716,6 +732,7 @@ export class ClawdbotAgentService { attempt.runLaunchManifestTraceId ?? `run-supervisor:${supervisor.id}`, runLaunchParentAttemptId: attempt.runLaunchParentAttemptId, runLaunchManifestDrift: attempt.runLaunchManifestDrift, + conversation: recoveredConversation, supervisorId: supervisor.id, recoveredControl: true, threadId: attempt.threadId ?? sessionId, @@ -1040,6 +1057,15 @@ export class ClawdbotAgentService { throw new Error('Task must have an active worktree to start an agent'); } + const conversationRequest = this.normalizeConversationLaunch(options.conversation); + const conversationSource = + conversationRequest.mode === 'fresh' + ? undefined + : this.conversationLifecycle.source( + await this.findAttempt(conversationRequest.sourceAttemptId as string), + conversationRequest.mode + ); + // Check if agent already running for this task if (pendingAgents.has(taskId)) { throw new ConflictError('An agent is already running for this task'); @@ -1142,7 +1168,10 @@ export class ClawdbotAgentService { const requiredRuntimeCapabilities = this.resolveLaunchRuntimeCapabilities( profileLaunch, budgetPolicy, - options.requiredRuntimeCapabilities + [ + ...(options.requiredRuntimeCapabilities ?? []), + ...conversationLaunchCapabilities(conversationRequest.mode), + ] ); const sandboxPolicy = await getSandboxPolicyService().dryRunWithTrace({ presetId: @@ -1208,10 +1237,22 @@ export class ClawdbotAgentService { profileInstructions: profileLaunch?.instructions, checkpoint: task.checkpoint, }); + const providerTransport = + conversationRequest.mode === 'fresh' + ? taskTransport + : { + ...taskTransport, + content: renderConversationTurn( + conversationRequest.mode, + conversationSource as ConversationSource, + conversationRequest.message as string, + conversationRequest.forkTurnId + ), + }; const runLaunchManifest = await this.compileRunLaunchManifest({ task, taskEnvelope, - taskTransport, + taskTransport: providerTransport, attemptId, startedAt, logPath, @@ -1233,7 +1274,10 @@ export class ClawdbotAgentService { budgetSources, options, }); - const parentAttempt = await this.resolveParentAttempt(task, options.parentAttemptId); + const parentAttempt = await this.resolveParentAttempt( + task, + conversationSource?.attempt.id ?? options.parentAttemptId + ); const runLaunchManifestDrift = parentAttempt?.runLaunchManifest ? diffRunLaunchManifests(runLaunchManifest, parentAttempt.runLaunchManifest) : undefined; @@ -1268,6 +1312,20 @@ export class ClawdbotAgentService { }, }); this.runLaunchManifests.assertEnforceable(runLaunchManifest); + if (conversationSource && conversationRequest.mode !== 'fresh') { + this.conversationLifecycle.assertCompatible( + conversationSource, + runLaunchManifest, + taskEnvelope, + conversationRequest.mode + ); + } + const conversation = this.conversationLifecycle.create( + conversationRequest.mode, + conversationSource, + conversationRequest.forkTurnId, + conversationRequest.intent + ); // Create event emitter for status updates const emitter = new EventEmitter(); @@ -1289,6 +1347,7 @@ export class ClawdbotAgentService { runLaunchManifestTraceId: runLaunchTrace.id, runLaunchParentAttemptId: parentAttempt?.id, runLaunchManifestDrift, + conversation, budget: budgetPolicy ? { ...budgetService.initialState(budgetPolicy), @@ -1308,7 +1367,7 @@ export class ClawdbotAgentService { logPath, task, agent, - taskTransport.content, + providerTransport.content, providerRuntimeManifest, taskEnvelope, runLaunchManifest @@ -1331,6 +1390,7 @@ export class ClawdbotAgentService { runLaunchManifestTraceId: runLaunchTrace.id, runLaunchParentAttemptId: parentAttempt?.id, runLaunchManifestDrift, + conversation, }; const usesManagedWorktree = Boolean(task.git.worktreeManifestId && task.git.worktreeLeaseId); @@ -1405,7 +1465,7 @@ export class ClawdbotAgentService { await this.taskService.patchTaskAttempt(taskId, attemptId, { runSupervisorId: supervisorId, }); - await this.appendRunEvent( + const startedEvent = await this.appendRunEvent( taskId, attemptId, 'run.started', @@ -1424,6 +1484,32 @@ export class ClawdbotAgentService { dedupeKey: 'run.started', } ); + await this.appendRunEvent( + taskId, + attemptId, + conversation.intent === 'fresh' + ? 'conversation.started' + : conversation.intent === 'resume' + ? 'conversation.resumed' + : conversation.intent === 'follow-up' + ? 'conversation.followed-up' + : 'conversation.forked', + { + mode: conversation.mode, + intent: conversation.intent, + parentAttemptId: conversation.parentAttemptId, + parentConversationId: conversation.parentConversationId, + forkTurnId: conversation.forkTurnId, + }, + { + provider, + adapter: adapter.id, + agent, + model: launchAgentConfig?.model, + causalEventId: startedEvent.eventId, + dedupeKey: `conversation.${conversation.mode}`, + } + ); if (profileLaunch) { await activityService.logActivity( @@ -1457,7 +1543,7 @@ export class ClawdbotAgentService { await adapter.start({ task, agentConfig: launchAgentConfig, - transport: taskTransport, + transport: providerTransport, logPath, attemptId, startedAt, @@ -1465,6 +1551,7 @@ export class ClawdbotAgentService { attempt, sandboxPolicy: sandboxPolicy.result, runLaunchManifest, + conversation, }); } catch (error: unknown) { const startError = error instanceof Error ? error : new Error(String(error)); @@ -1554,6 +1641,7 @@ export class ClawdbotAgentService { runLaunchManifest, runLaunchParentAttemptId: parentAttempt?.id, runLaunchManifestDrift, + conversation, controls: providerRuntimeControls(providerRuntimeManifest), }; } @@ -2288,6 +2376,7 @@ export class ClawdbotAgentService { runLaunchManifestTraceId: pending.runLaunchManifestTraceId, runLaunchParentAttemptId: pending.runLaunchParentAttemptId, runLaunchManifestDrift: pending.runLaunchManifestDrift, + conversation: pending.conversation, completionResult, }; return (pending.preparedCompletion = { @@ -2575,22 +2664,7 @@ export class ClawdbotAgentService { await this.finalizePendingAgent(taskId, pending, async () => { await this.assertPendingRunControl(taskId, pending, 'stop'); - if (pending.recoveredControl && pending.supervisorId) { - const supervisor = await this.runSupervisor.get(pending.supervisorId); - if (supervisor.control.kind === 'local-process') { - await this.runSupervisor.stopLocalProcess(pending.supervisorId); - } else { - await this.resolveProviderAdapter(pending.provider).stop({ taskId, pending }); - } - } else { - await this.resolveProviderAdapter(pending.provider).stop({ taskId, pending }); - if (pending.supervisorId) { - const supervisor = await this.runSupervisor.get(pending.supervisorId); - if (supervisor.control.kind === 'local-process') { - await this.runSupervisor.stopLocalProcess(pending.supervisorId); - } - } - } + await this.stopPendingProvider(pending); await this.appendRunEvent( taskId, pending.attemptId, @@ -2620,6 +2694,31 @@ export class ClawdbotAgentService { }); } + private async stopPendingProvider(pending: PendingAgent): Promise { + if (pending.recoveredControl && pending.supervisorId) { + const supervisor = await this.runSupervisor.get(pending.supervisorId); + if (supervisor.control.kind === 'local-process') { + await this.runSupervisor.stopLocalProcess(pending.supervisorId); + } else { + await this.resolveProviderAdapter(pending.provider).stop({ + taskId: pending.taskId, + pending, + }); + } + return; + } + + await this.resolveProviderAdapter(pending.provider).stop({ + taskId: pending.taskId, + pending, + }); + if (!pending.supervisorId) return; + const supervisor = await this.runSupervisor.get(pending.supervisorId); + if (supervisor.control.kind === 'local-process') { + await this.runSupervisor.stopLocalProcess(pending.supervisorId); + } + } + async sendMessage( taskId: string, message: string, @@ -2674,24 +2773,281 @@ export class ClawdbotAgentService { model: pending.model, }); - if (pending.provider === 'codex-app-server') { + if (pending.provider === 'codex-app-server' && pending.codexAppServerControl) { + const turnId = await pending.codexAppServerControl.steer(content); + const conversation = await this.recordConversationIdentity(taskId, pending.attemptId, { + turnId, + }); + await this.appendRunEvent( + taskId, + pending.attemptId, + 'conversation.steered', + { + actor, + conversationId: conversation.conversationId, + turnId, + }, + { + provider: pending.provider, + adapter: pending.provider, + agent: pending.agent, + model: pending.model, + causalEventId: journalEvent.eventId, + dedupeKey: `conversation.steered:${journalEvent.eventId}`, + } + ); return { - delivered: false, - note: 'Codex app-server steering remains disabled until provider-neutral lifecycle controls are active.', + action: 'steer', + taskId, + attemptId: pending.attemptId, + delivered: true, + note: 'Message delivered through provider-native turn steering.', + conversation, }; } - if (pending.process?.stdin?.writable) { - pending.process.stdin.write(`${content}\n`); - return { delivered: true, note: 'Message written to provider stdin.' }; - } - return { + action: 'steer', + taskId, + attemptId: pending.attemptId, delivered: false, - note: 'Provider does not expose interactive stdin; message was recorded and streamed.', + note: 'Provider does not expose a verified native steering control; message was recorded only.', + conversation: pending.conversation, }; } + async resumeConversation( + taskId: string, + sourceAttemptId: string, + message: string, + options: Omit = {} + ): Promise { + const source = this.conversationLifecycle.source( + await this.findAttempt(sourceAttemptId), + 'resume' + ); + return this.startAgent(taskId, source.attempt.agent, { + ...options, + parentAttemptId: source.attempt.id, + conversation: { mode: 'resume', intent: 'resume', sourceAttemptId, message }, + }); + } + + async followUpConversation( + taskId: string, + sourceAttemptId: string, + message: string, + options: Omit = {} + ): Promise { + const source = this.conversationLifecycle.source( + await this.findAttempt(sourceAttemptId), + 'resume' + ); + return this.startAgent(taskId, source.attempt.agent, { + ...options, + parentAttemptId: source.attempt.id, + conversation: { + mode: 'resume', + intent: 'follow-up', + sourceAttemptId, + message, + }, + }); + } + + async forkConversation( + taskId: string, + sourceAttemptId: string, + message: string, + forkTurnId?: string, + options: Omit = {} + ): Promise { + const source = this.conversationLifecycle.source( + await this.findAttempt(sourceAttemptId), + 'fork' + ); + return this.startAgent(taskId, source.attempt.agent, { + ...options, + parentAttemptId: source.attempt.id, + conversation: { + mode: 'fork', + intent: 'fork', + sourceAttemptId, + message, + ...(forkTurnId ? { forkTurnId } : {}), + }, + }); + } + + async compactConversation( + taskId: string, + attemptId: string, + actor = 'operator' + ): Promise { + const pending = this.assertPendingConversation(taskId, attemptId); + await this.assertPendingRunControl(taskId, pending, 'compact'); + if (!pending.codexAppServerControl) { + throw new ConflictError('The active provider has no native compaction control.'); + } + await pending.codexAppServerControl.compact(); + const conversation = await this.transitionPendingConversation(taskId, pending, 'compacted'); + await this.recordConversationControlEvent(taskId, pending, 'compact', actor, conversation); + return { + action: 'compact', + taskId, + attemptId, + delivered: true, + note: 'Provider-native conversation compaction started.', + conversation, + }; + } + + async archiveConversation( + taskId: string, + attemptId: string, + actor = 'operator' + ): Promise { + const pending = this.assertPendingConversation(taskId, attemptId); + let conversation = pending.conversation; + await this.finalizePendingAgent(taskId, pending, async () => { + await this.assertPendingRunControl(taskId, pending, 'archive'); + if (!pending.codexAppServerControl) { + throw new ConflictError('The active provider has no native archive control.'); + } + await pending.codexAppServerControl.archive(); + conversation = await this.transitionPendingConversation(taskId, pending, 'archived'); + await this.recordConversationControlEvent(taskId, pending, 'archive', actor, conversation); + pending.codexAppServerControl.close(); + return { + status: 'interrupted', + terminalSource: 'operator-interruption', + error: 'Conversation archived by operator', + }; + }); + return { + action: 'archive', + taskId, + attemptId, + delivered: true, + note: 'Provider-native conversation archive completed.', + conversation, + }; + } + + async closeConversation( + taskId: string, + attemptId: string, + actor = 'operator' + ): Promise { + const pending = this.assertPendingConversation(taskId, attemptId); + let conversation = pending.conversation; + await this.finalizePendingAgent(taskId, pending, async () => { + await this.assertPendingRunControl(taskId, pending, 'close'); + await pending.codexAppServerControl?.interrupt(); + conversation = await this.transitionPendingConversation(taskId, pending, 'closed'); + await this.recordConversationControlEvent(taskId, pending, 'close', actor, conversation); + pending.codexAppServerControl?.close(); + return { + status: 'interrupted', + terminalSource: 'operator-interruption', + error: 'Conversation closed by operator', + }; + }); + return { + action: 'close', + taskId, + attemptId, + delivered: true, + note: 'Conversation closed and any active provider turn was interrupted.', + conversation, + }; + } + + async interruptConversation( + taskId: string, + attemptId: string, + actor = 'operator' + ): Promise { + const pending = this.assertPendingConversation(taskId, attemptId); + const conversation = pending.conversation; + await this.finalizePendingAgent(taskId, pending, async () => { + await this.assertPendingRunControl(taskId, pending, 'interrupt'); + await this.stopPendingProvider(pending); + await this.recordConversationControlEvent(taskId, pending, 'interrupt', actor, conversation); + return { + status: 'interrupted', + terminalSource: 'operator-interruption', + error: 'Conversation interrupted by operator', + }; + }); + return { + action: 'interrupt', + taskId, + attemptId, + delivered: true, + note: 'Provider turn interrupted.', + conversation, + }; + } + + private assertPendingConversation(taskId: string, attemptId: string): PendingAgent { + const pending = pendingAgents.get(taskId); + if (!pending || pending.attemptId !== attemptId) { + throw new ConflictError('Conversation control does not match the active attempt.', { + taskId, + requestedAttemptId: attemptId, + activeAttemptId: pending?.attemptId, + }); + } + return pending; + } + + private async transitionPendingConversation( + taskId: string, + pending: PendingAgent, + state: 'compacted' | 'archived' | 'closed' + ): Promise { + const conversation = this.conversationLifecycle.transition(pending.conversation, state); + pending.conversation = conversation; + await this.taskService.patchTaskAttempt(taskId, pending.attemptId, { conversation }); + return conversation; + } + + private async recordConversationControlEvent( + taskId: string, + pending: PendingAgent, + action: 'interrupt' | 'compact' | 'archive' | 'close', + actor: string, + conversation: ConversationLifecycleRecord + ): Promise { + const event = await this.appendRunEvent( + taskId, + pending.attemptId, + action === 'interrupt' + ? 'conversation.interrupted' + : action === 'compact' + ? 'conversation.compacted' + : action === 'archive' + ? 'conversation.archived' + : 'conversation.closed', + { + action, + actor: actor.trim() || 'operator', + conversationId: conversation.conversationId, + turnId: conversation.currentTurnId, + state: conversation.state, + }, + { + provider: 'operator', + adapter: 'veritas-conversation-lifecycle', + agent: pending.agent, + model: pending.model, + dedupeKey: `conversation.${action}:${conversation.updatedAt}`, + } + ); + this.emitJournalOutput(event); + } + async recordBudgetUsage( taskId: string, attemptId: string, @@ -3324,6 +3680,9 @@ export class ClawdbotAgentService { await this.taskService.patchTaskAttempt(task.id, attemptId, { sessionKey: result.sessionKey, }); + await this.recordConversationIdentity(task.id, attemptId, { + conversationId: result.sessionKey, + }); void this.recordAgentStarted( task, attemptId, @@ -3638,6 +3997,28 @@ export class ClawdbotAgentService { if (!threadId || !turnId || terminalResult) return; await rpcClient.interrupt(threadId, turnId); }, + steer: async (message) => { + if (!threadId || !turnId || terminalResult) { + throw new ConflictError('Codex app-server has no steerable active turn.'); + } + const steeredTurnId = await rpcClient.steer(threadId, turnId, message); + if (steeredTurnId !== turnId) { + throw new ConflictError('Codex app-server steering changed the active turn identity.'); + } + return steeredTurnId; + }, + compact: async () => { + if (!threadId || !turnId || terminalResult) { + throw new ConflictError('Codex app-server has no active conversation to compact.'); + } + await rpcClient.compact(threadId); + }, + archive: async () => { + if (!threadId) { + throw new ConflictError('Codex app-server has no conversation to archive.'); + } + await rpcClient.archive(threadId); + }, close: () => requestGracefulClose('Codex app-server attempt was stopped.'), }; @@ -3717,6 +4098,21 @@ export class ClawdbotAgentService { ); if (classified.summary) finalSummary = classified.summary; if (classified.usage) tokenUsage = classified.usage; + if (classified.sessionId || classified.turnId || classified.itemId) { + await this.recordConversationIdentity(task.id, attemptId, { + conversationId: classified.sessionId, + turnId: classified.turnId, + itemId: classified.itemId, + }); + } + if (classified.usage) { + await this.recordConversationContext( + task.id, + attemptId, + classified.usage.totalTokens, + classified.usage.modelContextWindow + ); + } if (classified.terminal) { terminalResult = classified.terminal; requestGracefulClose('Codex app-server turn reached a terminal state.'); @@ -3820,13 +4216,32 @@ export class ClawdbotAgentService { agentConfig ); await rpcClient.initialize(); - threadId = await rpcClient.startThread({ + const threadInput = { cwd: worktreePath, model: agentConfig?.model, sandboxMode: sandboxPolicy?.effective.sandboxMode ?? 'workspace-write', + }; + threadId = + pending.conversation.mode === 'resume' + ? await rpcClient.resumeThread({ + ...threadInput, + threadId: requireConversationId(pending.conversation, 'Codex app-server resume'), + }) + : pending.conversation.mode === 'fork' + ? await rpcClient.forkThread({ + ...threadInput, + threadId: requireParentConversationId( + pending.conversation, + 'Codex app-server fork' + ), + ...(pending.conversation.forkTurnId + ? { lastTurnId: pending.conversation.forkTurnId } + : {}), + }) + : await rpcClient.startThread(threadInput); + await this.recordConversationIdentity(task.id, attemptId, { + conversationId: threadId, }); - pending.threadId = threadId; - await this.taskService.patchTaskAttempt(task.id, attemptId, { threadId }); if (pending.supervisorId) { await this.runSupervisor.checkpoint(pending.supervisorId, { sessionId: threadId, @@ -3839,6 +4254,7 @@ export class ClawdbotAgentService { cwd: worktreePath, model: agentConfig?.model, }); + await this.recordConversationIdentity(task.id, attemptId, { turnId }); await this.appendLog( logPath, `\n## Codex app-server Session\n\n**Thread:** ${threadId}\n**Turn:** ${turnId}\n` @@ -4226,11 +4642,31 @@ export class ClawdbotAgentService { const effectivePrompt = repositoryInstructions ? `${prompt}\n\n# Repository Instructions\n\n${repositoryInstructions}` : prompt; + const pending = pendingAgents.get(task.id); + if (!pending || pending.attemptId !== attemptId) { + throw new ConflictError('Claude Code launch was cancelled before process spawn.', { + taskId: task.id, + attemptId, + }); + } const command = agentConfig?.command || 'claude'; const args = buildClaudeCodeArgs({ prompt: effectivePrompt, model: agentConfig?.model, extraArgs: agentConfig?.args, + ...(pending.conversation.mode === 'resume' + ? { + resumeSessionId: requireConversationId(pending.conversation, 'Claude Code resume'), + } + : pending.conversation.mode === 'fork' + ? { + resumeSessionId: requireParentConversationId( + pending.conversation, + 'Claude Code fork' + ), + forkSession: true, + } + : {}), sandboxMode: sandboxPolicy?.effective.sandboxMode ?? 'workspace-write', networkAccessEnabled: sandboxPolicy?.effective.networkAccessEnabled ?? true, maxBudgetUsd: runLaunchManifest.budget.enabled @@ -4254,13 +4690,6 @@ export class ClawdbotAgentService { agentConfig ); - const pending = pendingAgents.get(task.id); - if (!pending || pending.attemptId !== attemptId) { - throw new ConflictError('Claude Code launch was cancelled before process spawn.', { - taskId: task.id, - attemptId, - }); - } const child = spawn(command, args, { cwd: worktreePath, env: buildSafeClaudeCodeEnv(process.env, sandboxPolicy?.effective.envPassthrough), @@ -4300,7 +4729,10 @@ export class ClawdbotAgentService { logPath ); if (classified.summary) finalSummary = classified.summary; - if (classified.usage) tokenUsage = classified.usage; + if (classified.usage) { + tokenUsage = classified.usage; + await this.recordConversationContext(task.id, attemptId, classified.usage.totalTokens); + } if (classified.terminal) terminalResult = classified.terminal; if (classified.sessionId && classified.sessionId !== recordedSessionId) { recordedSessionId = classified.sessionId; @@ -4641,15 +5073,7 @@ export class ClawdbotAgentService { attemptId: string, sessionId: string ): Promise { - const pending = pendingAgents.get(task.id); - if (pending && pending.attemptId === attemptId) pending.threadId = sessionId; - await this.taskService.patchTaskAttempt(task.id, attemptId, { threadId: sessionId }); - if (pending?.supervisorId && pending.attemptId === attemptId) { - await this.runSupervisor.checkpoint(pending.supervisorId, { - sessionId, - threadId: sessionId, - }); - } + await this.recordConversationIdentity(task.id, attemptId, { conversationId: sessionId }); } private async startHermesCli( @@ -4843,23 +5267,28 @@ export class ClawdbotAgentService { throw new Error('Task worktree path is required for Codex CLI'); } + const pending = pendingAgents.get(task.id); + if (!pending || pending.attemptId !== attemptId) { + throw new ConflictError('Codex CLI launch was cancelled before process spawn.', { + taskId: task.id, + attemptId, + }); + } const command = agentConfig?.command || 'codex'; - const args = this.buildCodexArgs(agentConfig, prompt, logPath, attemptId, sandboxPolicy); + const args = this.buildCodexArgs( + agentConfig, + prompt, + logPath, + attemptId, + sandboxPolicy, + pending.conversation + ); const child = spawn(command, args, { cwd: worktreePath, env: buildSafeCodexEnv(process.env, sandboxPolicy?.effective.envPassthrough), shell: false, detached: process.platform !== 'win32', }); - - const pending = pendingAgents.get(task.id); - if (!pending || pending.attemptId !== attemptId) { - child.kill('SIGTERM'); - throw new ConflictError('Codex CLI launch was cancelled before process spawn.', { - taskId: task.id, - attemptId, - }); - } pending.process = child; await this.attachSpawnedProcess(pending, child); @@ -5051,7 +5480,8 @@ export class ClawdbotAgentService { prompt: string, logPath: string, attemptId: string, - sandboxPolicy?: SandboxPolicyDryRunResult + sandboxPolicy?: SandboxPolicyDryRunResult, + conversation?: ConversationLifecycleRecord ): string[] { const configured = agentConfig?.args?.length ? [...agentConfig.args] : ['exec']; const args = configured.includes('exec') ? configured : ['exec', ...configured]; @@ -5066,7 +5496,14 @@ export class ClawdbotAgentService { if (!args.includes('--output-last-message')) { args.push('--output-last-message', this.getCodexFinalPath(logPath, attemptId)); } - args.push(prompt); + if (conversation?.mode === 'resume') { + if (!conversation.conversationId) { + throw new ConflictError('Codex CLI resume requires an exact conversation ID.'); + } + args.push('resume', conversation.conversationId, prompt); + } else { + args.push(prompt); + } return args; } @@ -5097,11 +5534,30 @@ export class ClawdbotAgentService { env: buildSafeCodexEnv(process.env, sandboxPolicy?.effective.envPassthrough), }); - const thread = codex.startThread({ + const pending = pendingAgents.get(task.id); + if (!pending || pending.attemptId !== attemptId) { + throw new ConflictError('Codex SDK launch was cancelled before thread creation.', { + taskId: task.id, + attemptId, + }); + } + const threadSettings = { workingDirectory: worktreePath, ...this.buildCodexSdkThreadSettings(sandboxPolicy), model: agentConfig?.model, - }); + }; + const thread = + pending.conversation.mode === 'resume' + ? codex.resumeThread( + requireConversationId(pending.conversation, 'Codex SDK resume'), + threadSettings + ) + : codex.startThread(threadSettings); + if (pending.conversation.mode === 'resume') { + await this.recordConversationIdentity(task.id, attemptId, { + conversationId: requireConversationId(pending.conversation, 'Codex SDK resume'), + }); + } await this.appendLog( logPath, @@ -5145,6 +5601,11 @@ export class ClawdbotAgentService { } if (tokenUsage) { + await this.recordConversationContext( + task.id, + attemptId, + tokenUsage.totalTokens ?? tokenUsage.inputTokens + tokenUsage.outputTokens + ); await this.assertRunControl(task.id, 'token-usage', attemptId); await getTelemetryService().emit({ type: 'run.tokens', @@ -5879,12 +6340,58 @@ export class ClawdbotAgentService { } private async recordCodexThread(task: Task, attemptId: string, threadId: string): Promise { - const pending = pendingAgents.get(task.id); - if (pending) { - pending.threadId = threadId; - } + await this.recordConversationIdentity(task.id, attemptId, { conversationId: threadId }); + } - await this.taskService.patchTaskAttempt(task.id, attemptId, { threadId }); + private async recordConversationIdentity( + taskId: string, + attemptId: string, + identity: { conversationId?: string; turnId?: string; itemId?: string } + ): Promise { + const pending = pendingAgents.get(taskId); + if (!pending || pending.attemptId !== attemptId) { + throw new ConflictError('Conversation identity no longer matches the active attempt.', { + taskId, + attemptId, + }); + } + const conversation = this.conversationLifecycle.bind(pending.conversation, identity); + pending.conversation = conversation; + if (identity.conversationId) pending.threadId = identity.conversationId; + await this.taskService.patchTaskAttempt(taskId, attemptId, { + ...(identity.conversationId ? { threadId: identity.conversationId } : {}), + conversation, + }); + if (pending.supervisorId) { + await this.runSupervisor.checkpoint(pending.supervisorId, { + sessionId: conversation.conversationId, + threadId: conversation.conversationId, + }); + } + return conversation; + } + + private async recordConversationContext( + taskId: string, + attemptId: string, + usedTokens: number, + limitTokens?: number + ): Promise { + const pending = pendingAgents.get(taskId); + if (!pending || pending.attemptId !== attemptId) { + throw new ConflictError('Conversation context no longer matches the active attempt.', { + taskId, + attemptId, + }); + } + const conversation = this.conversationLifecycle.recordContext( + pending.conversation, + usedTokens, + limitTokens + ); + pending.conversation = conversation; + await this.taskService.patchTaskAttempt(taskId, attemptId, { conversation }); + return conversation; } private extractCodexSummary(event: unknown): string | undefined { @@ -6005,6 +6512,7 @@ export class ClawdbotAgentService { runLaunchManifest: pending.runLaunchManifest, runLaunchParentAttemptId: pending.runLaunchParentAttemptId, runLaunchManifestDrift: pending.runLaunchManifestDrift, + conversation: pending.conversation, controls: providerRuntimeControls(pending.providerRuntimeManifest), }; } @@ -6269,7 +6777,8 @@ export class ClawdbotAgentService { input.logPath, input.attemptId, input.sandboxPolicy, - input.budgetPolicy + input.budgetPolicy, + input.options.conversation ); const worktreePath = input.task.git?.worktreePath ? this.expandPath(input.task.git.worktreePath) @@ -6875,7 +7384,8 @@ export class ClawdbotAgentService { logPath: string, attemptId: string, sandboxPolicy: SandboxPolicyDryRunResult, - budgetPolicy?: AgentBudgetPolicy + budgetPolicy?: AgentBudgetPolicy, + conversationRequest?: ConversationLaunchRequest ): RunLaunchRuntime { const environment = this.buildRunLaunchEnvironment(provider, sandboxPolicy); const runtimeBase = { @@ -6889,9 +7399,14 @@ export class ClawdbotAgentService { return { ...runtimeBase, command: agentConfig?.command || 'codex', - args: this.buildCodexArgs(agentConfig, '', logPath, attemptId, sandboxPolicy).map( - (argument) => (argument === finalPath ? '/final-message.md' : argument) - ), + args: this.buildCodexArgs( + agentConfig, + '', + logPath, + attemptId, + sandboxPolicy, + manifestConversation(conversationRequest) + ).map((argument) => (argument === finalPath ? '/final-message.md' : argument)), }; } if (provider === 'codex-sdk') { @@ -6901,7 +7416,8 @@ export class ClawdbotAgentService { ...runtimeBase, command: sdkExecutable.manifestCommand, args: [ - 'startThread', + conversationRequest?.mode === 'resume' ? 'resumeThread' : 'startThread', + ...(conversationRequest?.mode === 'resume' ? [''] : []), `skipGitRepoCheck=${threadSettings.skipGitRepoCheck}`, `sandboxMode=${threadSettings.sandboxMode}`, `approvalPolicy=${threadSettings.approvalPolicy}`, @@ -6926,6 +7442,11 @@ export class ClawdbotAgentService { prompt: '', model: agentConfig?.model, extraArgs: agentConfig?.args, + ...(conversationRequest?.mode === 'resume' + ? { resumeSessionId: '' } + : conversationRequest?.mode === 'fork' + ? { resumeSessionId: '', forkSession: true } + : {}), sandboxMode: sandboxPolicy.effective.sandboxMode, networkAccessEnabled: sandboxPolicy.effective.networkAccessEnabled, maxBudgetUsd: budgetPolicy?.enabled ? budgetPolicy.limits?.costUsd : undefined, @@ -7111,12 +7632,7 @@ export class ClawdbotAgentService { const currentTaskParent = [task.attempt, ...(task.attempts ?? [])] .filter((attempt): attempt is TaskAttempt => Boolean(attempt)) .find((attempt) => attempt.id === parentAttemptId); - const parent = - currentTaskParent ?? - (await this.taskService.listTasks()) - .flatMap((candidate) => [candidate.attempt, ...(candidate.attempts ?? [])]) - .filter((attempt): attempt is TaskAttempt => Boolean(attempt)) - .find((attempt) => attempt.id === parentAttemptId); + const parent = currentTaskParent ?? (await this.findAttempt(parentAttemptId)); if (!parent) { throw new ConflictError('Parent attempt was not found for launch-manifest comparison.', { parentAttemptId, @@ -7130,6 +7646,61 @@ export class ClawdbotAgentService { return parent as TaskAttempt & { runLaunchManifest: RunLaunchManifest }; } + private async findAttempt(attemptId: string): Promise { + return (await this.taskService.listTasks()) + .flatMap((candidate) => [candidate.attempt, ...(candidate.attempts ?? [])]) + .filter((attempt): attempt is TaskAttempt => Boolean(attempt)) + .find((attempt) => attempt.id === attemptId); + } + + private normalizeConversationLaunch( + request: ConversationLaunchRequest | undefined + ): ConversationLaunchRequest & { mode: 'fresh' | 'resume' | 'fork' } { + if (!request || request.mode === 'fresh') { + if ( + request?.sourceAttemptId || + request?.forkTurnId || + (request?.intent && request.intent !== 'fresh') + ) { + throw new ConflictError('Fresh conversation launch cannot reference prior history.'); + } + return { mode: 'fresh', intent: 'fresh' }; + } + const intent = request.intent ?? request.mode; + if ( + (request.mode === 'resume' && !['resume', 'follow-up'].includes(intent)) || + (request.mode === 'fork' && intent !== 'fork') + ) { + throw new ConflictError( + `Conversation ${intent} is incompatible with ${request.mode} launch mode.` + ); + } + const sourceAttemptId = request.sourceAttemptId?.trim(); + const message = request.message?.trim(); + if (!sourceAttemptId || sourceAttemptId.length > 120) { + throw new ConflictError(`Conversation ${request.mode} requires a valid source attempt ID.`); + } + if (!message || Buffer.byteLength(message, 'utf8') > 20_000) { + throw new ConflictError( + `Conversation ${request.mode} requires a non-empty follow-up message of at most 20,000 bytes.` + ); + } + if (request.mode === 'resume' && request.forkTurnId) { + throw new ConflictError('Conversation resume cannot specify a fork turn.'); + } + const forkTurnId = request.forkTurnId?.trim(); + if (forkTurnId && forkTurnId.length > 240) { + throw new ConflictError('Conversation fork turn ID exceeds the supported limit.'); + } + return { + mode: request.mode, + intent, + sourceAttemptId, + message, + ...(forkTurnId ? { forkTurnId } : {}), + }; + } + private async initLogFile( logPath: string, task: Task, @@ -7200,6 +7771,63 @@ export const clawdbotAgentService = new ClawdbotAgentService( } ); +function conversationLaunchCapabilities( + mode: 'fresh' | 'resume' | 'fork' +): ProviderRuntimeCapabilityId[] { + if (mode === 'fresh') return []; + return mode === 'resume' ? ['run.resume', 'run.follow-up'] : ['run.fork', 'run.follow-up']; +} + +function manifestConversation( + request: ConversationLaunchRequest | undefined +): ConversationLifecycleRecord | undefined { + if (!request || request.mode === 'fresh') return undefined; + const timestamp = '1970-01-01T00:00:00.000Z'; + return { + schemaVersion: CONVERSATION_LIFECYCLE_SCHEMA_VERSION, + mode: request.mode, + intent: request.intent ?? request.mode, + ...(request.mode === 'resume' ? { conversationId: '' } : {}), + ...(request.mode === 'fork' ? { parentConversationId: '' } : {}), + state: 'active', + contextWindow: { posture: 'unknown', measuredAt: timestamp }, + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +function requireConversationId(record: ConversationLifecycleRecord, action: string): string { + if (!record.conversationId) { + throw new ConflictError(`${action} requires a durable conversation ID.`); + } + return record.conversationId; +} + +function requireParentConversationId(record: ConversationLifecycleRecord, action: string): string { + if (!record.parentConversationId) { + throw new ConflictError(`${action} requires a durable parent conversation ID.`); + } + return record.parentConversationId; +} + +function renderConversationTurn( + mode: 'resume' | 'fork', + source: ConversationSource, + message: string, + forkTurnId?: string +): string { + return `# Conversation ${mode === 'resume' ? 'Follow-Up' : 'Fork'} + +- Lifecycle: \`${CONVERSATION_LIFECYCLE_SCHEMA_VERSION}\` +- Source attempt: \`${source.attempt.id}\` +- Source conversation: \`${source.conversationId}\` +${forkTurnId ? `- Fork through turn: \`${forkTurnId}\`\n` : ''} +## Operator Input + +${message} +`; +} + function taskStatusForCompletion(status: TaskCompletionStatus): 'done' | 'blocked' | 'in-progress' { if (status === 'success') return 'done'; if (status === 'blocked') return 'blocked'; diff --git a/server/src/services/codex-app-server-adapter.ts b/server/src/services/codex-app-server-adapter.ts index 452797c2..578ed444 100644 --- a/server/src/services/codex-app-server-adapter.ts +++ b/server/src/services/codex-app-server-adapter.ts @@ -21,9 +21,14 @@ import serverNotificationSchema from '../contracts/codex-app-server-v0.145.0/Ser import serverRequestSchema from '../contracts/codex-app-server-v0.145.0/ServerRequest.json' with { type: 'json' }; import toolRequestUserInputResponseSchema from '../contracts/codex-app-server-v0.145.0/ToolRequestUserInputResponse.json' with { type: 'json' }; import initializeResponseSchema from '../contracts/codex-app-server-v0.145.0/v1/InitializeResponse.json' with { type: 'json' }; +import threadArchiveResponseSchema from '../contracts/codex-app-server-v0.145.0/v2/ThreadArchiveResponse.json' with { type: 'json' }; +import threadCompactStartResponseSchema from '../contracts/codex-app-server-v0.145.0/v2/ThreadCompactStartResponse.json' with { type: 'json' }; +import threadForkResponseSchema from '../contracts/codex-app-server-v0.145.0/v2/ThreadForkResponse.json' with { type: 'json' }; +import threadResumeResponseSchema from '../contracts/codex-app-server-v0.145.0/v2/ThreadResumeResponse.json' with { type: 'json' }; import threadStartResponseSchema from '../contracts/codex-app-server-v0.145.0/v2/ThreadStartResponse.json' with { type: 'json' }; import turnInterruptResponseSchema from '../contracts/codex-app-server-v0.145.0/v2/TurnInterruptResponse.json' with { type: 'json' }; import turnStartResponseSchema from '../contracts/codex-app-server-v0.145.0/v2/TurnStartResponse.json' with { type: 'json' }; +import turnSteerResponseSchema from '../contracts/codex-app-server-v0.145.0/v2/TurnSteerResponse.json' with { type: 'json' }; import { buildSafeCodexEnv } from '../utils/codex-env.js'; export const CODEX_APP_SERVER_CERTIFIED_VERSION = 'codex-cli 0.145.0'; @@ -43,7 +48,12 @@ const MAX_IDENTIFIER_LENGTH = 256; export const CODEX_APP_SERVER_OUTBOUND_METHODS = [ 'initialize', 'thread/start', + 'thread/resume', + 'thread/fork', + 'thread/compact/start', + 'thread/archive', 'turn/start', + 'turn/steer', 'turn/interrupt', ] as const; @@ -64,7 +74,12 @@ const validateJsonRpcError = ajv.compile(jsonRpcErrorSchema as object); const responseValidators: Record> = { initialize: ajv.compile(initializeResponseSchema as object), 'thread/start': ajv.compile(threadStartResponseSchema as object), + 'thread/resume': ajv.compile(threadResumeResponseSchema as object), + 'thread/fork': ajv.compile(threadForkResponseSchema as object), + 'thread/compact/start': ajv.compile(threadCompactStartResponseSchema as object), + 'thread/archive': ajv.compile(threadArchiveResponseSchema as object), 'turn/start': ajv.compile(turnStartResponseSchema as object), + 'turn/steer': ajv.compile(turnSteerResponseSchema as object), 'turn/interrupt': ajv.compile(turnInterruptResponseSchema as object), }; const serverRequestResponseValidators: Partial>> = { @@ -94,10 +109,19 @@ export interface CodexAppServerTurnInput { model?: string; } +export interface CodexAppServerResumeInput extends CodexAppServerThreadInput { + threadId: string; +} + +export interface CodexAppServerForkInput extends CodexAppServerResumeInput { + lastTurnId?: string; +} + export interface CodexAppServerUsage { inputTokens: number; outputTokens: number; totalTokens: number; + modelContextWindow?: number; } export interface CodexAppServerTerminalResult { @@ -249,6 +273,7 @@ export function classifyCodexAppServerNotification( const turn = recordValue(params.turn); const tokenUsage = recordValue(params.tokenUsage); const totalUsage = recordValue(tokenUsage?.total); + const modelContextWindow = boundedNumber(tokenUsage?.modelContextWindow); const turnStatus = stringValue(turn?.status); const turnError = recordValue(turn?.error); const summary = boundedSummary( @@ -262,6 +287,7 @@ export function classifyCodexAppServerNotification( inputTokens: boundedNumber(totalUsage.inputTokens) ?? 0, outputTokens: boundedNumber(totalUsage.outputTokens) ?? 0, totalTokens: boundedNumber(totalUsage.totalTokens) ?? 0, + ...(modelContextWindow && modelContextWindow > 0 ? { modelContextWindow } : {}), } : undefined; const terminal = @@ -333,6 +359,48 @@ export class CodexAppServerRpcClient { return requiredNestedIdentifier(result, 'thread', 'id', 'Codex app-server thread/start'); } + async resumeThread(input: CodexAppServerResumeInput): Promise { + this.assertInitialized(); + const threadId = requiredIdentifier(input.threadId, 'Codex app-server thread ID'); + const result = await this.request('thread/resume', { + threadId, + cwd: input.cwd, + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandbox: input.sandboxMode, + ...(input.model?.trim() ? { model: input.model.trim() } : {}), + excludeTurns: true, + }); + const resumed = requiredNestedIdentifier( + result, + 'thread', + 'id', + 'Codex app-server thread/resume' + ); + if (resumed !== threadId) { + throw new Error('Codex app-server resumed a different thread than requested.'); + } + return resumed; + } + + async forkThread(input: CodexAppServerForkInput): Promise { + this.assertInitialized(); + const result = await this.request('thread/fork', { + threadId: requiredIdentifier(input.threadId, 'Codex app-server parent thread ID'), + cwd: input.cwd, + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandbox: input.sandboxMode, + ...(input.model?.trim() ? { model: input.model.trim() } : {}), + ...(input.lastTurnId + ? { lastTurnId: requiredIdentifier(input.lastTurnId, 'Codex app-server fork turn ID') } + : {}), + excludeTurns: true, + deferGoalContinuation: true, + }); + return requiredNestedIdentifier(result, 'thread', 'id', 'Codex app-server thread/fork'); + } + async startTurn(input: CodexAppServerTurnInput): Promise { this.assertInitialized(); const result = await this.request('turn/start', { @@ -354,6 +422,32 @@ export class CodexAppServerRpcClient { }); } + async steer(threadId: string, turnId: string, prompt: string): Promise { + this.assertInitialized(); + const text = prompt.trim(); + if (!text) throw new Error('Codex app-server steering input cannot be empty.'); + const result = await this.request('turn/steer', { + threadId: requiredIdentifier(threadId, 'Codex app-server thread ID'), + expectedTurnId: requiredIdentifier(turnId, 'Codex app-server turn ID'), + input: [{ type: 'text', text }], + }); + return requiredIdentifier(recordValue(result)?.turnId, 'Codex app-server steered turn ID'); + } + + async compact(threadId: string): Promise { + this.assertInitialized(); + await this.request('thread/compact/start', { + threadId: requiredIdentifier(threadId, 'Codex app-server thread ID'), + }); + } + + async archive(threadId: string): Promise { + this.assertInitialized(); + await this.request('thread/archive', { + threadId: requiredIdentifier(threadId, 'Codex app-server thread ID'), + }); + } + async acceptRecord(record: Record): Promise { const method = stringValue(record.method); const hasId = record.id !== undefined; diff --git a/server/src/services/conversation-lifecycle-service.ts b/server/src/services/conversation-lifecycle-service.ts new file mode 100644 index 00000000..0372e644 --- /dev/null +++ b/server/src/services/conversation-lifecycle-service.ts @@ -0,0 +1,284 @@ +import { + CONVERSATION_LIFECYCLE_SCHEMA_VERSION, + type ConversationContextWindow, + type ConversationLaunchIntent, + type ConversationLaunchMode, + type ConversationLifecycleRecord, + type ConversationState, + type RunLaunchManifest, + type TaskAttempt, + type TaskEnvelope, +} from '@veritas-kanban/shared'; +import { ConflictError } from '../middleware/error-handler.js'; +import { digestRunLaunchValue } from '../utils/run-launch-manifest-digest.js'; + +export interface ConversationSource { + attempt: TaskAttempt; + conversationId: string; +} + +export class ConversationLifecycleService { + constructor(private readonly now: () => Date = () => new Date()) {} + + recover(attempt: TaskAttempt, providerConversationId?: string): ConversationLifecycleRecord { + if (attempt.conversation) return attempt.conversation; + return this.bind(this.create('fresh'), { + conversationId: attempt.threadId ?? providerConversationId, + }); + } + + source(attempt: TaskAttempt | undefined, mode: Exclude) { + if (!attempt) { + throw new ConflictError(`Conversation ${mode} source attempt was not found.`); + } + if (!['complete', 'failed'].includes(attempt.status)) { + throw new ConflictError( + `Conversation ${mode} requires a terminal source attempt; interrupt or finish it first.`, + { sourceAttemptId: attempt.id, sourceStatus: attempt.status } + ); + } + const conversationId = attempt.conversation?.conversationId ?? attempt.threadId; + if (!conversationId) { + throw new ConflictError(`Conversation ${mode} source has no durable provider identity.`, { + sourceAttemptId: attempt.id, + }); + } + if (!attempt.providerRuntimeManifest || !attempt.runLaunchManifest || !attempt.taskEnvelope) { + throw new ConflictError( + `Conversation ${mode} source has no complete runtime, task, and launch evidence.`, + { sourceAttemptId: attempt.id } + ); + } + if ( + attempt.conversation?.state && + ['archived', 'closed'].includes(attempt.conversation.state) + ) { + throw new ConflictError(`Conversation ${mode} source is ${attempt.conversation.state}.`, { + sourceAttemptId: attempt.id, + }); + } + return { attempt, conversationId }; + } + + create( + mode: ConversationLaunchMode, + source?: ConversationSource, + forkTurnId?: string, + intent: ConversationLaunchIntent = mode + ) { + if ( + (intent === 'follow-up' && mode !== 'resume') || + (intent !== 'follow-up' && intent !== mode) + ) { + throw new ConflictError(`Conversation ${intent} is incompatible with ${mode} launch mode.`); + } + const timestamp = this.now().toISOString(); + return { + schemaVersion: CONVERSATION_LIFECYCLE_SCHEMA_VERSION, + mode, + intent, + ...(mode === 'resume' && source ? { conversationId: source.conversationId } : {}), + ...(source + ? { + parentConversationId: source.conversationId, + parentAttemptId: source.attempt.id, + } + : {}), + ...(forkTurnId ? { forkTurnId } : {}), + state: 'active', + contextWindow: unknownContextWindow(timestamp), + createdAt: timestamp, + updatedAt: timestamp, + } satisfies ConversationLifecycleRecord; + } + + assertCompatible( + source: ConversationSource, + target: RunLaunchManifest, + targetTaskEnvelope: TaskEnvelope, + mode: Exclude + ): void { + const parent = source.attempt.runLaunchManifest as RunLaunchManifest; + const parentTaskEnvelope = source.attempt.taskEnvelope as TaskEnvelope; + const mismatches: string[] = []; + if (parent.providerRuntime.provider !== target.providerRuntime.provider) { + mismatches.push('provider'); + } + if (parent.providerRuntime.adapter !== target.providerRuntime.adapter) { + mismatches.push('adapter'); + } + if (parent.providerRuntime.protocolVersion !== target.providerRuntime.protocolVersion) { + mismatches.push('protocolVersion'); + } + if (parent.providerRuntime.materialDigest !== target.providerRuntime.materialDigest) { + mismatches.push('runtimeEvidence'); + } + if ((parent.runtime.model ?? '') !== (target.runtime.model ?? '')) { + mismatches.push('model'); + } + if (parent.runtime.command !== target.runtime.command) mismatches.push('command'); + if (parent.runtime.workingDirectory !== target.runtime.workingDirectory) { + mismatches.push('workingDirectory'); + } + if (!sameJson(parent.runtime.environmentKeys, target.runtime.environmentKeys)) { + mismatches.push('environment'); + } + if (!sameJson(parent.runtime.credentialReferences, target.runtime.credentialReferences)) { + mismatches.push('credentials'); + } + if ( + !sameJson( + { + profileId: parent.harnessSupport.profileId, + adapterId: parent.harnessSupport.adapterId, + transport: parent.harnessSupport.transport, + }, + { + profileId: target.harnessSupport.profileId, + adapterId: target.harnessSupport.adapterId, + transport: target.harnessSupport.transport, + } + ) + ) { + mismatches.push('harnessSupport'); + } + if (!sameJson(parent.profile, target.profile)) mismatches.push('profile'); + if (parent.routing.selectedHost !== target.routing.selectedHost) mismatches.push('host'); + if (!sameJson(parent.sandbox.effective, target.sandbox.effective)) { + mismatches.push('sandbox'); + } + if (!sameJson(parent.tools, target.tools)) mismatches.push('tools'); + if (!sameJson(parent.permissions, target.permissions)) mismatches.push('permissions'); + if (!sameJson(parent.resources, target.resources)) mismatches.push('resources'); + if (!sameJson(parent.requiredHealthChecks, target.requiredHealthChecks)) { + mismatches.push('healthChecks'); + } + if (!sameJson(parent.budget, target.budget)) mismatches.push('budget'); + if (!sameJson(parent.workspaceTrust, target.workspaceTrust)) mismatches.push('workspaceTrust'); + if (!sameJson(persistentInstructionDigests(parent), persistentInstructionDigests(target))) { + mismatches.push('instructions'); + } + if (parentTaskEnvelope.commitPolicy !== targetTaskEnvelope.commitPolicy) { + mismatches.push('commitPolicy'); + } + if (!sameJson(parentTaskEnvelope.allowedSideEffects, targetTaskEnvelope.allowedSideEffects)) { + mismatches.push('allowedSideEffects'); + } + const parentWorkspace = parent.workspace; + const targetWorkspace = target.workspace; + if (!parentWorkspace || !targetWorkspace) { + mismatches.push('workspaceEvidence'); + } else { + if (parentWorkspace.repo !== targetWorkspace.repo) mismatches.push('workspaceRepo'); + if (parentWorkspace.baseBranch !== targetWorkspace.baseBranch) { + mismatches.push('workspaceBaseBranch'); + } + if (parentWorkspace.resolvedBaseCommit !== targetWorkspace.resolvedBaseCommit) { + mismatches.push('workspaceBaseCommit'); + } + if ( + mode === 'resume' && + (parentWorkspace.worktreeManifestId ?? parentWorkspace.worktreeId) !== + (targetWorkspace.worktreeManifestId ?? targetWorkspace.worktreeId) + ) { + mismatches.push('worktree'); + } + } + + if (mismatches.length > 0) { + throw new ConflictError(`Conversation ${mode} is incompatible with the requested launch.`, { + sourceAttemptId: source.attempt.id, + mismatches, + remediation: + 'Use a fresh conversation or restore the source provider, model, policy, and worktree baseline.', + }); + } + } + + bind( + record: ConversationLifecycleRecord, + identity: { conversationId?: string; turnId?: string; itemId?: string } + ): ConversationLifecycleRecord { + const timestamp = this.now().toISOString(); + return { + ...record, + ...(identity.conversationId ? { conversationId: identity.conversationId } : {}), + ...(identity.turnId ? { currentTurnId: identity.turnId } : {}), + ...(identity.itemId ? { lastItemId: identity.itemId } : {}), + updatedAt: timestamp, + }; + } + + recordContext( + record: ConversationLifecycleRecord, + usedTokens: number, + limitTokens?: number + ): ConversationLifecycleRecord { + const timestamp = this.now().toISOString(); + const boundedUsed = positiveInteger(usedTokens); + const boundedLimit = limitTokens === undefined ? undefined : positiveInteger(limitTokens); + const utilization = + boundedLimit && boundedLimit > 0 ? Math.min(1, boundedUsed / boundedLimit) : undefined; + const posture = + utilization === undefined + ? 'unknown' + : utilization >= 0.9 + ? 'critical' + : utilization >= 0.75 + ? 'nearing-limit' + : 'healthy'; + return { + ...record, + contextWindow: { + usedTokens: boundedUsed, + ...(boundedLimit + ? { + limitTokens: boundedLimit, + remainingTokens: Math.max(0, boundedLimit - boundedUsed), + utilization, + } + : {}), + posture, + measuredAt: timestamp, + }, + updatedAt: timestamp, + }; + } + + transition( + record: ConversationLifecycleRecord, + state: Exclude + ): ConversationLifecycleRecord { + const timestamp = this.now().toISOString(); + return { + ...record, + state, + updatedAt: timestamp, + ...(state === 'compacted' + ? { compactedAt: timestamp } + : state === 'archived' + ? { archivedAt: timestamp } + : { closedAt: timestamp }), + }; + } +} + +function unknownContextWindow(measuredAt: string): ConversationContextWindow { + return { posture: 'unknown', measuredAt }; +} + +function positiveInteger(value: number): number { + if (!Number.isFinite(value) || value < 0) return 0; + return Math.floor(value); +} + +function sameJson(left: unknown, right: unknown): boolean { + return digestRunLaunchValue(left) === digestRunLaunchValue(right); +} + +function persistentInstructionDigests(manifest: RunLaunchManifest): string[] { + return manifest.instructions + .filter((instruction) => instruction.kind !== 'task') + .map((instruction) => instruction.materialDigest) + .sort(); +} diff --git a/server/src/services/provider-runtime-adapter-registry.ts b/server/src/services/provider-runtime-adapter-registry.ts index b84b3b78..9c03c7b0 100644 --- a/server/src/services/provider-runtime-adapter-registry.ts +++ b/server/src/services/provider-runtime-adapter-registry.ts @@ -30,9 +30,12 @@ const CLI_SANDBOX: ProviderRuntimeCapabilityOverrides = { }; const NOT_YET_IMPLEMENTED: ProviderRuntimeCapabilityOverrides = { - 'run.follow-up': unsupported('Provider-neutral follow-up turns are tracked by issue #856.'), - 'run.steer': unsupported('Provider-neutral steering is tracked by issue #856.'), - 'run.fork': unsupported('Provider-neutral conversation forks are tracked by issue #856.'), + 'run.follow-up': unsupported('The adapter does not expose provider-native follow-up turns.'), + 'run.steer': unsupported('The adapter does not expose provider-native steering.'), + 'run.fork': unsupported('The adapter does not expose provider-native history forks.'), + 'run.compact': unsupported('The adapter does not expose provider-native compaction.'), + 'run.archive': unsupported('The adapter does not expose provider-native archival.'), + 'run.close': unsupported('The adapter does not expose provider-native conversation closure.'), 'run.reattach': unsupported('Durable provider reattachment is tracked by issue #853.'), 'run.approvals': unsupported('Provider-native approvals are tracked by issue #852.'), 'run.elicitation': unsupported('Provider-native elicitation is tracked by issue #852.'), @@ -49,8 +52,9 @@ const DEFINITIONS: Record`.'), 'run.interrupt': advisory('Process termination is available; semantic interrupt is not.'), - 'run.resume': unsupported('Codex CLI resume is not wired into task execution.'), + 'run.resume': supported('Codex CLI resumes a persisted session by its exact ID.'), 'tool.calls': supported('Codex tool events are parsed and recorded.'), 'output.structured': advisory( 'Structured events are available without output-schema enforcement.' @@ -66,10 +70,9 @@ const DEFINITIONS: Record { expect(baseElement.querySelector('[data-slot="alert-dialog-content"]')).toBeNull(); await user.click(screen.getByRole('button', { name: 'Stop Agent' })); - expect(mocks.sendMessageMutate).toHaveBeenCalledWith({ - taskId: 'task-agent-running', - attemptId: 'attempt-1', - message: 'continue with tests', - }); + expect(mocks.sendMessageMutate).toHaveBeenCalledWith( + { + taskId: 'task-agent-running', + attemptId: 'attempt-1', + message: 'continue with tests', + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ); expect(mocks.stopAgentMutate).toHaveBeenCalledWith({ taskId: 'task-agent-running', attemptId: 'attempt-1', diff --git a/web/src/components/task/AgentPanel.tsx b/web/src/components/task/AgentPanel.tsx index b9688eb2..f29e4062 100644 --- a/web/src/components/task/AgentPanel.tsx +++ b/web/src/components/task/AgentPanel.tsx @@ -49,6 +49,7 @@ import { cn } from '@/lib/utils'; import { sanitizeText } from '@/lib/sanitize'; import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary'; import { useIdentity } from '@/hooks/useIdentity'; +import { useToast } from '@/hooks/useToast'; import { clientAllowsLocalAgentControls } from '@/lib/client-policy'; import { RunSessionSharesSection } from './RunSessionSharesSection'; @@ -78,6 +79,7 @@ export function AgentPanel({ task, onOpenTimeline }: AgentPanelProps) { const { data: attempts, refetch: refetchAttempts } = useAgentAttempts(task.id); const { data: routingResult } = useResolveAgent(task.id); const { authContext, hasPermission } = useIdentity(); + const { toast } = useToast(); const startAgent = useStartAgent(); const stopAgent = useStopAgent(); @@ -183,12 +185,33 @@ export function AgentPanel({ task, onOpenTimeline }: AgentPanelProps) { e.preventDefault(); if (!message.trim() || !canSendMessage || !agentStatus?.attemptId) return; - sendMessage.mutate({ - taskId: task.id, - attemptId: agentStatus.attemptId, - message: message.trim(), - }); - setMessage(''); + sendMessage.mutate( + { + taskId: task.id, + attemptId: agentStatus.attemptId, + message: message.trim(), + }, + { + onSuccess: (result) => { + if (result.delivered) { + setMessage(''); + return; + } + toast({ + title: 'Message not delivered', + description: result.note, + variant: 'destructive', + }); + }, + onError: (error) => { + toast({ + title: 'Message not delivered', + description: error instanceof Error ? error.message : 'Unknown error', + variant: 'destructive', + }); + }, + } + ); }; // Check if we can start an agent diff --git a/web/src/hooks/useAgent.ts b/web/src/hooks/useAgent.ts index 25722cbb..bf0ff78f 100644 --- a/web/src/hooks/useAgent.ts +++ b/web/src/hooks/useAgent.ts @@ -8,11 +8,13 @@ import type { AgentHealthClassificationResponse, AgentHostPreviewRequest, AgentType, + ConversationLifecycleResult, ProviderRuntimeCapabilityId, RunApprovalDecisionInput, RunApprovalRequest, TaskCommitPolicy, } from '@veritas-kanban/shared'; +import type { ConversationTurnRequest } from '@/lib/api/agent'; export interface StartAgentInput { taskId: string; @@ -97,6 +99,69 @@ export function useSendMessage() { }); } +function invalidateConversationQueries( + queryClient: ReturnType, + taskId: string +): void { + queryClient.invalidateQueries({ queryKey: ['agent', 'status', taskId] }); + queryClient.invalidateQueries({ queryKey: ['agent', 'attempts', taskId] }); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); +} + +export function useResumeConversation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ taskId, request }: { taskId: string; request: ConversationTurnRequest }) => + api.agent.resumeConversation(taskId, request), + onSuccess: (_, { taskId }) => invalidateConversationQueries(queryClient, taskId), + }); +} + +export function useFollowUpConversation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ taskId, request }: { taskId: string; request: ConversationTurnRequest }) => + api.agent.followUpConversation(taskId, request), + onSuccess: (_, { taskId }) => invalidateConversationQueries(queryClient, taskId), + }); +} + +export function useForkConversation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ taskId, request }: { taskId: string; request: ConversationTurnRequest }) => + api.agent.forkConversation(taskId, request), + onSuccess: (_, { taskId }) => invalidateConversationQueries(queryClient, taskId), + }); +} + +type ConversationControlAction = 'interrupt' | 'compact' | 'archive' | 'close'; + +export function useConversationControl(action: ConversationControlAction) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + taskId, + attemptId, + }: { + taskId: string; + attemptId: string; + }): Promise => { + if (action === 'interrupt') { + return api.agent.interruptConversation(taskId, attemptId); + } + if (action === 'compact') { + return api.agent.compactConversation(taskId, attemptId); + } + if (action === 'archive') { + return api.agent.archiveConversation(taskId, attemptId); + } + return api.agent.closeConversation(taskId, attemptId); + }, + onSuccess: (_, { taskId }) => invalidateConversationQueries(queryClient, taskId), + }); +} + export function useStopAgent() { const queryClient = useQueryClient(); diff --git a/web/src/lib/api/agent.ts b/web/src/lib/api/agent.ts index 41b982a8..d0f08548 100644 --- a/web/src/lib/api/agent.ts +++ b/web/src/lib/api/agent.ts @@ -9,6 +9,8 @@ import type { AgentRoutingConfig, RoutingResult, AgentBudgetPolicy, + ConversationLifecycleRecord, + ConversationLifecycleResult, ProviderRuntimeManifest, ProviderRuntimeCapabilityId, ProviderRuntimeControlSet, @@ -37,6 +39,18 @@ export interface StartAgentRequest { parentAttemptId?: string; } +export interface ConversationTurnRequest { + sourceAttemptId: string; + message: string; + forkTurnId?: string; + profileId?: string; + overrideReason?: string; + sandboxPresetId?: string; + budget?: AgentBudgetPolicy; + requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[]; + commitPolicy?: TaskCommitPolicy; +} + export const worktreeApi = { create: async (taskId: string, request: CreateWorktreeRequest = {}): Promise => { return apiFetch(`${API_BASE}/tasks/${taskId}/worktree`, { @@ -120,14 +134,110 @@ export const agentApi = { }); }, - sendMessage: async (taskId: string, attemptId: string, message: string): Promise => { - return apiFetch(`${API_BASE}/agents/${taskId}/message`, { + sendMessage: async ( + taskId: string, + attemptId: string, + message: string + ): Promise => { + return apiFetch( + `${API_BASE}/agents/${taskId}/conversation/steer`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ attemptId, message }), + } + ); + }, + + resumeConversation: async ( + taskId: string, + request: ConversationTurnRequest + ): Promise => { + return apiFetch(`${API_BASE}/agents/${taskId}/conversation/resume`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ attemptId, message }), + body: JSON.stringify(request), }); }, + followUpConversation: async ( + taskId: string, + request: ConversationTurnRequest + ): Promise => { + return apiFetch(`${API_BASE}/agents/${taskId}/conversation/follow-up`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + }, + + forkConversation: async ( + taskId: string, + request: ConversationTurnRequest + ): Promise => { + return apiFetch(`${API_BASE}/agents/${taskId}/conversation/fork`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + }, + + interruptConversation: async ( + taskId: string, + attemptId: string + ): Promise => { + return apiFetch( + `${API_BASE}/agents/${taskId}/conversation/interrupt`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ attemptId }), + } + ); + }, + + compactConversation: async ( + taskId: string, + attemptId: string + ): Promise => { + return apiFetch( + `${API_BASE}/agents/${taskId}/conversation/compact`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ attemptId }), + } + ); + }, + + archiveConversation: async ( + taskId: string, + attemptId: string + ): Promise => { + return apiFetch( + `${API_BASE}/agents/${taskId}/conversation/archive`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ attemptId }), + } + ); + }, + + closeConversation: async ( + taskId: string, + attemptId: string + ): Promise => { + return apiFetch( + `${API_BASE}/agents/${taskId}/conversation/close`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ attemptId }), + } + ); + }, + stop: async (taskId: string, attemptId: string): Promise => { return apiFetch(`${API_BASE}/agents/${taskId}/stop`, { method: 'POST', @@ -298,6 +408,7 @@ export interface AgentStatus { runLaunchManifest: RunLaunchManifest; runLaunchParentAttemptId?: string; runLaunchManifestDrift?: RunLaunchManifestDriftResult; + conversation: ConversationLifecycleRecord; controls: ProviderRuntimeControlSet; } @@ -316,6 +427,7 @@ export interface AgentStatusResponse { runLaunchManifest?: RunLaunchManifest; runLaunchParentAttemptId?: string; runLaunchManifestDrift?: RunLaunchManifestDriftResult; + conversation?: ConversationLifecycleRecord; controls?: ProviderRuntimeControlSet; }