feat: add provider-neutral conversation lifecycle (#856) (#958)

This commit is contained in:
Brad Groux 2026-07-24 04:07:40 -05:00 committed by GitHub
parent f8561d38fd
commit 6b9510c58f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 7007 additions and 170 deletions

View file

@ -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.
---

View file

@ -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

View file

@ -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 <task> --profile <id> # Launch a task with a profile package
vk agent:resume <task> --source-attempt <id> -m "Continue the work"
vk agent:fork <task> --source-attempt <id> --fork-turn <id> -m "Try another path"
vk agent:steer <task> --attempt <id> -m "Use the smaller fix"
vk agent:compact <task> --attempt <id>
```
### Utilities

View file

@ -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();

View file

@ -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<string> {
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} <id>`)
.description(description)
.requiredOption('--source-attempt <attemptId>', 'Terminal attempt with durable conversation')
.requiredOption('-m, --message <text>', 'Prompt for the new turn')
.option('-p, --profile <profileId>', 'Agent profile package to launch')
.option(
'--require-capability <capabilities...>',
'Require provider runtime capabilities before launch'
)
.option(
'--commit-policy <policy>',
'Commit policy for this run (forbidden, allowed, or required)'
)
.option('--json', 'Output as JSON');
if (action === 'fork') {
command.option('--fork-turn <turnId>', '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} <id>`)
.description(description)
.requiredOption('--attempt <attemptId>', '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<ConversationLifecycleResult>(
`/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 <id>')
.description('Steer the exact active provider turn')
.requiredOption('--attempt <attemptId>', 'Exact active attempt ID')
.requiredOption('-m, --message <text>', 'Steering message')
.option('--json', 'Output as JSON')
.action(
async (
id: string,
options: ConversationControlOptions & { message: string }
): Promise<void> => {
try {
const taskId = await resolveTaskId(id);
const result = await api<ConversationLifecycleResult>(
`/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')

View file

@ -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 <session-id>`
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

View file

@ -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",

View file

@ -450,6 +450,14 @@ Manage AI agents on code tasks.
| `vk start <id>` | Start an agent; optionally require runtime capabilities |
| `vk launch-preview <id>` | Preview effective launch inputs, blockers, and drift |
| `vk stop <id>` | Stop a run only when its persisted manifest supports stop |
| `vk agent:resume <id> --source-attempt <id> -m <text>` | Resume the exact persisted provider conversation |
| `vk agent:follow-up <id> --source-attempt <id> -m <text>` | Start a provider-native follow-up turn |
| `vk agent:fork <id> --source-attempt <id> -m <text>` | Fork provider history without mutating its source |
| `vk agent:steer <id> --attempt <id> -m <text>` | Steer the exact active provider turn |
| `vk agent:interrupt <id> --attempt <id>` | Interrupt the exact active provider turn |
| `vk agent:compact <id> --attempt <id>` | Compact a supported provider conversation |
| `vk agent:archive <id> --attempt <id>` | Archive a supported provider conversation |
| `vk agent:close <id> --attempt <id>` | Close a supported provider conversation |
| `vk agents:pending` | List pending agent requests |
| `vk agents:status <id>` | Check agent running status |
| `vk agents:complete <id> -s --attempt-id <id> --manifest-digest <sha256:...>` | 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 <id>` 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

View file

@ -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 <id>` | Start an agent on a code task (`--agent` to choose) |
| `vk launch-preview <id>` | Preview immutable launch evidence without dispatch |
| `vk stop <id>` | Stop a running agent |
| `vk agent:resume <id> --source-attempt <id> -m <text>` | Resume an exact provider conversation |
| `vk agent:follow-up <id> --source-attempt <id> -m <text>` | Start a native follow-up turn |
| `vk agent:fork <id> --source-attempt <id> -m <text>` | Fork native provider history |
| `vk agent:steer <id> --attempt <id> -m <text>` | Steer the exact active provider turn |
| `vk agent:interrupt <id> --attempt <id>` | Interrupt the exact active attempt |
| `vk agent:compact <id> --attempt <id>` | Compact a supported provider conversation |
| `vk agent:archive <id> --attempt <id>` | Archive a supported provider conversation |
| `vk agent:close <id> --attempt <id>` | Close a supported provider conversation |
| `vk agents:pending` | List pending agent requests |
| `vk agents:status <id>` | Check agent running status |
| `vk agents:complete <id> -s --attempt-id <id> --manifest-digest <sha256:...>` | Mark the matching agent attempt complete (success) |

View file

@ -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",

View file

@ -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',
}),
});
});
});

View file

@ -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<any> {
@ -151,6 +249,38 @@ export async function handleAgentTool(name: string, args: any): Promise<any> {
};
}
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}`);
}

View file

@ -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');

View file

@ -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');
});

View file

@ -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> = {}): 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',
});
});
});

View file

@ -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)}`;

View file

@ -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

View file

@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadArchiveResponse",
"type": "object"
}

View file

@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadCompactStartResponse",
"type": "object"
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "TurnSteerResponse",
"type": "object",
"required": ["turnId"],
"properties": {
"turnId": {
"type": "string"
}
}
}

View file

@ -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<typeof conversationTurnSchema> {
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<typeof conversationControlSchema> {
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<typeof conversationSteerSchema> {
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<typeof conversationTurnSchema>
): Omit<AgentStartOptions, 'conversation' | 'parentAttemptId'> {
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'
);
}

View file

@ -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) {

File diff suppressed because it is too large Load diff

View file

@ -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<CodexAppServerOutboundMethod, ValidateFunction<unknown>> = {
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<Record<string, ValidateFunction<unknown>>> = {
@ -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<string> {
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<string> {
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<string> {
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<string> {
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<void> {
this.assertInitialized();
await this.request('thread/compact/start', {
threadId: requiredIdentifier(threadId, 'Codex app-server thread ID'),
});
}
async archive(threadId: string): Promise<void> {
this.assertInitialized();
await this.request('thread/archive', {
threadId: requiredIdentifier(threadId, 'Codex app-server thread ID'),
});
}
async acceptRecord(record: Record<string, unknown>): Promise<CodexAppServerInbound> {
const method = stringValue(record.method);
const hasId = record.id !== undefined;

View file

@ -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<ConversationLaunchMode, 'fresh'>) {
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<ConversationLaunchMode, 'fresh'>
): 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<ConversationState, 'active'>
): 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();
}

View file

@ -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<ExecutableAgentProvider, ProviderRuntimeAdapterDefinit
),
'run.streaming': supported('Codex JSONL output is streamed into run events.'),
'run.structured-events': supported('Codex CLI emits contract-tested JSONL events.'),
'run.follow-up': supported('A follow-up starts through `codex exec resume <session-id>`.'),
'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<ExecutableAgentProvider, ProviderRuntimeAdapterDefinit
'run.stop': supported('The adapter aborts the active Codex SDK run.'),
'run.streaming': supported('Codex SDK thread events are streamed into run events.'),
'run.structured-events': supported('Codex SDK emits typed thread events.'),
'run.follow-up': supported('A follow-up runs on a resumed Codex SDK thread.'),
'run.interrupt': advisory('Abort is available; semantic interrupt is not wired.'),
'run.resume': advisory(
'Thread identity is retained, but task-level resume is not yet exposed.'
),
'run.resume': supported('The SDK resumes a persisted thread by its exact ID.'),
'tool.calls': supported('Codex SDK tool events are parsed and recorded.'),
'output.structured': advisory('Typed events are available without output-schema enforcement.'),
'usage.tokens': supported('Codex SDK token usage events are parsed and persisted.'),
@ -99,14 +102,25 @@ const DEFINITIONS: Record<ExecutableAgentProvider, ProviderRuntimeAdapterDefinit
'run.structured-events': supported(
'Every consumed request, response, notification, and provider request is checked against schemas generated by Codex CLI v0.145.0.'
),
'run.follow-up': supported(
'A follow-up resumes the exact thread and starts a new correlated turn.'
),
'run.steer': supported(
'The adapter sends turn/steer with exact thread and active-turn preconditions.'
),
'run.interrupt': supported(
'Task stop uses the correlated thread and turn IDs to send turn/interrupt.'
),
'run.resume': unsupported(
'Thread IDs are retained, but task-level resume remains gated by provider-neutral lifecycle issue #856.'
'run.resume': supported(
'The adapter validates and resumes a persisted thread through thread/resume.'
),
'run.fork': unsupported(
'The upstream method is not exposed until provider-neutral lifecycle issue #856 owns authorization and persistence.'
'run.fork': supported(
'The adapter forks an exact thread and optional last completed turn through thread/fork.'
),
'run.compact': supported('The adapter starts native thread compaction.'),
'run.archive': supported('The adapter archives the exact provider thread.'),
'run.close': supported(
'Veritas closes the local lifecycle after interrupting any active provider turn.'
),
'run.approvals': supported(
'Provider approval requests are bound to durable Veritas requests and resolved with authenticated compare-and-set decisions.'
@ -153,14 +167,15 @@ const DEFINITIONS: Record<ExecutableAgentProvider, ProviderRuntimeAdapterDefinit
'run.structured-events': supported(
'Claude Code emits contract-tested stream-json lifecycle records.'
),
'run.follow-up': supported(
'A follow-up resumes the exact Claude Code session in a new headless invocation.'
),
'run.interrupt': advisory(
'SIGTERM performs cooperative process interruption; semantic steering is not yet exposed.'
),
'run.resume': advisory(
'Claude session IDs are persisted, but task-level resume remains gated by issue #856.'
),
'run.fork': unsupported(
'Claude session forking remains gated by provider-neutral lifecycle controls in issue #856.'
'run.resume': supported('Claude Code resumes the exact persisted session with `--resume`.'),
'run.fork': supported(
'Claude Code forks a resumed session with `--resume` and `--fork-session`.'
),
'tool.calls': supported(
'Claude assistant tool-use and user tool-result records are journaled and budgeted.'

View file

@ -25,7 +25,12 @@ const CONTROL_DEFINITIONS: RuntimeControlDefinition[] = [
{ action: 'stop', label: 'Stop run', capabilityId: 'run.stop' },
{ action: 'interrupt', label: 'Interrupt run', capabilityId: 'run.interrupt' },
{ action: 'message', label: 'Steer run', capabilityId: 'run.steer' },
{ action: 'follow-up', label: 'Send follow-up turn', capabilityId: 'run.follow-up' },
{ action: 'resume', label: 'Resume run', capabilityId: 'run.resume' },
{ action: 'fork', label: 'Fork conversation', capabilityId: 'run.fork' },
{ action: 'compact', label: 'Compact conversation', capabilityId: 'run.compact' },
{ action: 'archive', label: 'Archive conversation', capabilityId: 'run.archive' },
{ action: 'close', label: 'Close conversation', capabilityId: 'run.close' },
{ action: 'reattach', label: 'Reattach run', capabilityId: 'run.reattach' },
{ action: 'approvals', label: 'Provider approvals', capabilityId: 'run.approvals' },
{ action: 'tool-calls', label: 'Tool calls', capabilityId: 'tool.calls' },

View file

@ -0,0 +1,69 @@
export const CONVERSATION_LIFECYCLE_SCHEMA_VERSION = 'conversation-lifecycle/v1' as const;
export const CONVERSATION_LAUNCH_MODES = ['fresh', 'resume', 'fork'] as const;
export type ConversationLaunchMode = (typeof CONVERSATION_LAUNCH_MODES)[number];
export const CONVERSATION_LAUNCH_INTENTS = ['fresh', 'resume', 'follow-up', 'fork'] as const;
export type ConversationLaunchIntent = (typeof CONVERSATION_LAUNCH_INTENTS)[number];
export const CONVERSATION_STATES = ['active', 'compacted', 'archived', 'closed'] as const;
export type ConversationState = (typeof CONVERSATION_STATES)[number];
export const CONVERSATION_CONTEXT_POSTURES = [
'unknown',
'healthy',
'nearing-limit',
'critical',
] as const;
export type ConversationContextPosture = (typeof CONVERSATION_CONTEXT_POSTURES)[number];
export interface ConversationContextWindow {
usedTokens?: number;
limitTokens?: number;
remainingTokens?: number;
utilization?: number;
posture: ConversationContextPosture;
measuredAt: string;
}
/**
* Durable provider-neutral identity for the history attached to one attempt.
*
* Provider identifiers are opaque. Credential leases and process handles are
* deliberately excluded so resume and fork never inherit transient authority.
*/
export interface ConversationLifecycleRecord {
schemaVersion: typeof CONVERSATION_LIFECYCLE_SCHEMA_VERSION;
mode: ConversationLaunchMode;
intent: ConversationLaunchIntent;
conversationId?: string;
currentTurnId?: string;
lastItemId?: string;
parentConversationId?: string;
parentAttemptId?: string;
forkTurnId?: string;
state: ConversationState;
contextWindow: ConversationContextWindow;
createdAt: string;
updatedAt: string;
compactedAt?: string;
archivedAt?: string;
closedAt?: string;
}
export interface ConversationLaunchRequest {
mode: ConversationLaunchMode;
intent?: ConversationLaunchIntent;
sourceAttemptId?: string;
forkTurnId?: string;
message?: string;
}
export interface ConversationLifecycleResult {
action: 'resume' | 'follow-up' | 'fork' | 'steer' | 'interrupt' | 'compact' | 'archive' | 'close';
taskId: string;
attemptId: string;
delivered: boolean;
note: string;
conversation: ConversationLifecycleRecord;
}

View file

@ -56,3 +56,4 @@ export * from './auth.types.js';
export * from './credential-broker.types.js';
export * from './worktree-manifest.types.js';
export * from './run-approval.types.js';
export * from './conversation-lifecycle.types.js';

View file

@ -110,7 +110,7 @@ export interface HarnessSupportStatus {
export const PROVIDER_RUNTIME_MANIFEST_SCHEMA_VERSION = 'provider-runtime-manifest/v1' as const;
export const PROVIDER_RUNTIME_PROBE_REVISION = 7 as const;
export const PROVIDER_RUNTIME_PROBE_REVISION = 8 as const;
export const KNOWN_PROVIDER_RUNTIME_CAPABILITY_IDS = [
'run.start',
@ -125,6 +125,9 @@ export const KNOWN_PROVIDER_RUNTIME_CAPABILITY_IDS = [
'run.interrupt',
'run.resume',
'run.fork',
'run.compact',
'run.archive',
'run.close',
'run.reattach',
'run.approvals',
'run.elicitation',
@ -242,7 +245,12 @@ export const PROVIDER_RUNTIME_CONTROL_ACTIONS = [
'stop',
'interrupt',
'message',
'follow-up',
'resume',
'fork',
'compact',
'archive',
'close',
'reattach',
'approvals',
'tool-calls',

View file

@ -8,6 +8,15 @@ export const RUN_EVENT_KINDS = [
'run.failed',
'run.interrupted',
'run.recovered',
'conversation.started',
'conversation.resumed',
'conversation.followed-up',
'conversation.forked',
'conversation.steered',
'conversation.interrupted',
'conversation.compacted',
'conversation.archived',
'conversation.closed',
'message.operator',
'message.assistant',
'message.delta',

View file

@ -81,6 +81,7 @@ export interface TaskAttempt {
runLaunchParentAttemptId?: string;
runLaunchManifestDrift?: import('./run-launch-manifest.types.js').RunLaunchManifestDriftResult;
completionResult?: import('./task-envelope.types.js').CompletionResult;
conversation?: import('./conversation-lifecycle.types.js').ConversationLifecycleRecord;
}
export interface Subtask {

View file

@ -181,6 +181,16 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
{ methods: ['POST'], path: /^\/[^/]+\/launch-preview\/?$/, permissions: 'agent:read' },
{ methods: ['POST'], path: /^\/[^/]+\/(start|stop)\/?$/, permissions: 'agent:write' },
{ methods: ['POST'], path: /^\/[^/]+\/message\/?$/, permissions: 'task:write' },
{
methods: ['POST'],
path: /^\/[^/]+\/conversation\/steer\/?$/,
permissions: 'task:write',
},
{
methods: ['POST'],
path: /^\/[^/]+\/conversation\/(resume|follow-up|fork|interrupt|compact|archive|close)\/?$/,
permissions: 'agent:write',
},
],
},
{

View file

@ -366,11 +366,17 @@ describe('task detail agent, template, and metrics Mantine migration', () => {
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',

View file

@ -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

View file

@ -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<typeof useQueryClient>,
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<ConversationLifecycleResult> => {
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();

View file

@ -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<WorktreeInfo> => {
return apiFetch<WorktreeInfo>(`${API_BASE}/tasks/${taskId}/worktree`, {
@ -120,14 +134,110 @@ export const agentApi = {
});
},
sendMessage: async (taskId: string, attemptId: string, message: string): Promise<void> => {
return apiFetch<void>(`${API_BASE}/agents/${taskId}/message`, {
sendMessage: async (
taskId: string,
attemptId: string,
message: string
): Promise<ConversationLifecycleResult> => {
return apiFetch<ConversationLifecycleResult>(
`${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<AgentStatus> => {
return apiFetch<AgentStatus>(`${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<AgentStatus> => {
return apiFetch<AgentStatus>(`${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<AgentStatus> => {
return apiFetch<AgentStatus>(`${API_BASE}/agents/${taskId}/conversation/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
},
interruptConversation: async (
taskId: string,
attemptId: string
): Promise<ConversationLifecycleResult> => {
return apiFetch<ConversationLifecycleResult>(
`${API_BASE}/agents/${taskId}/conversation/interrupt`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attemptId }),
}
);
},
compactConversation: async (
taskId: string,
attemptId: string
): Promise<ConversationLifecycleResult> => {
return apiFetch<ConversationLifecycleResult>(
`${API_BASE}/agents/${taskId}/conversation/compact`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attemptId }),
}
);
},
archiveConversation: async (
taskId: string,
attemptId: string
): Promise<ConversationLifecycleResult> => {
return apiFetch<ConversationLifecycleResult>(
`${API_BASE}/agents/${taskId}/conversation/archive`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attemptId }),
}
);
},
closeConversation: async (
taskId: string,
attemptId: string
): Promise<ConversationLifecycleResult> => {
return apiFetch<ConversationLifecycleResult>(
`${API_BASE}/agents/${taskId}/conversation/close`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attemptId }),
}
);
},
stop: async (taskId: string, attemptId: string): Promise<void> => {
return apiFetch<void>(`${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;
}