mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
parent
f8561d38fd
commit
6b9510c58f
39 changed files with 7007 additions and 170 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -173,8 +173,12 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
|
|||
- App-server launch arguments are system-owned. Inherited MCP servers, hooks,
|
||||
plugins, apps, browser/computer tools, and remote control remain disabled.
|
||||
- App-server consumes only the checked-in v0.145.0 schemas and exposes
|
||||
`initialize`, `thread/start`, `turn/start`, and `turn/interrupt`.
|
||||
`thread/shellCommand` is never reachable.
|
||||
`initialize`, thread start/resume/fork/compact/archive, and turn
|
||||
start/steer/interrupt. `thread/shellCommand` is never reachable.
|
||||
- `conversation-lifecycle/v1` persists opaque thread, turn, item, parent, and
|
||||
fork identities. Resume and fork validate the source launch manifest,
|
||||
provider/model/policy, base revision, and worktree compatibility before a new
|
||||
attempt is created.
|
||||
- App-server command, file, permission, tool-question, and elicitation requests
|
||||
use `run-approval/v1`. Decisions must preserve the persisted revision and
|
||||
exact action hash; interruption and cancellation invalidate pending requests.
|
||||
|
|
@ -190,9 +194,12 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
|
|||
- The terminal `result` record is authoritative. Veritas drains stdout after
|
||||
process close, persists `session_id`, and maps partial, hook, tool, subagent,
|
||||
usage, cost, and result records into `run-event/v1`.
|
||||
- Resume, fork, and MCP injection remain fail-closed. The shared approval broker
|
||||
is available, but Claude stays on static `dontAsk` permissions until its
|
||||
adapter exposes a pinned interactive request/response contract.
|
||||
- Resume uses the exact persisted session through system-owned `--resume`.
|
||||
Native history fork adds `--fork-session`; caller-supplied lifecycle flags
|
||||
remain prohibited. MCP injection remains fail-closed.
|
||||
- The shared approval broker is available, but Claude stays on static
|
||||
`dontAsk` permissions until its adapter exposes a pinned interactive
|
||||
request/response contract.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -26,6 +26,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- Added `conversation-lifecycle/v1` with durable provider conversation, turn,
|
||||
item, parent, and fork identities; launch-manifest and worktree compatibility
|
||||
checks; context-window posture; causal lifecycle events; attributed REST,
|
||||
CLI, MCP, and web controls; and explicit unsupported delivery results.
|
||||
Codex CLI and SDK can resume exact persisted sessions, Claude Code can resume
|
||||
or fork with system-owned native flags, and Codex app-server can resume,
|
||||
follow up, steer, fork, compact, archive, interrupt, and close through
|
||||
schema-validated JSON-RPC methods. Generic process stdin is no longer treated
|
||||
as a successful provider follow-up path, and capability evidence advances to
|
||||
probe revision 8 (#856).
|
||||
- Added `run-supervisor/v1`, a durable provider-run ownership record with exact
|
||||
provider, task-envelope, launch-manifest, and worktree bindings; persisted
|
||||
process groups or remote-session handles; budget and event cursors; expiring
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
314
server/src/__tests__/conversation-lifecycle-service.test.ts
Normal file
314
server/src/__tests__/conversation-lifecycle-service.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ThreadArchiveResponse",
|
||||
"type": "object"
|
||||
}
|
||||
|
|
@ -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
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "TurnSteerResponse",
|
||||
"type": "object",
|
||||
"required": ["turnId"],
|
||||
"properties": {
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
284
server/src/services/conversation-lifecycle-service.ts
Normal file
284
server/src/services/conversation-lifecycle-service.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
69
shared/src/types/conversation-lifecycle.types.ts
Normal file
69
shared/src/types/conversation-lifecycle.types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue