Merge PR #415 into Ralph tool stack
This commit is contained in:
@@ -204,19 +204,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'register(definition: CommandDefinition): () => void',
|
||||
jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata, surface mask, and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */',
|
||||
jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[]',
|
||||
jsDoc: '/**\n * List the effective immutable command descriptors for one agent and surface.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter requesting discovery metadata.\n * @returns name-sorted descriptors after scoped shadowing and surface filtering.\n */',
|
||||
signature: 'list(agent: Agent): readonly CommandDescriptor[]',
|
||||
jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined',
|
||||
jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter performing the lookup.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition when visible on the surface.\n */',
|
||||
signature: 'find(agent: Agent, name: string): CommandDefinition | undefined',
|
||||
jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>',
|
||||
jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param surface - dispatching UI adapter.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax/name/surface does not resolve.\n */',
|
||||
signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandResult | undefined>',
|
||||
jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax or name does not resolve.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -276,10 +276,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'goals',
|
||||
summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'resolveCreate(request: CreateGoalRequest): CreateGoalSpec',
|
||||
jsDoc: '/**\n * Materialize deployment defaults and validate one create request.\n * @param request - objective plus optional caller-selected round cap.\n * @returns detached, fully resolved create specification.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(agent: Agent): GoalView | undefined',
|
||||
jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */',
|
||||
@@ -309,16 +305,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'block(agent: Agent, ref: GoalRef): GoalView',
|
||||
jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the blocked view.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'markUsageLimited(agent: Agent, ref: GoalRef): GoalView',
|
||||
jsDoc: '/**\n * Mark an active goal stopped by an external usage limit.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the usage-limited view.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'markBudgetLimited(agent: Agent, ref: GoalRef): GoalView',
|
||||
jsDoc: '/**\n * Mark an active goal stopped at its configured round cap.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the budget-limited view.\n */',
|
||||
signature: 'block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView',
|
||||
jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'clear(agent: Agent, ref: GoalRef): GoalRef',
|
||||
@@ -1139,11 +1127,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CommandDefinition',
|
||||
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces?: readonly CommandSurface[];\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
|
||||
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandDescriptor',
|
||||
declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces: readonly CommandSurface[];\n}',
|
||||
declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandInputDescriptor',
|
||||
@@ -1151,16 +1139,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CommandInvocation',
|
||||
declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly surface: CommandSurface;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
|
||||
declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandResult',
|
||||
declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};',
|
||||
},
|
||||
{
|
||||
name: 'CommandSurface',
|
||||
declaration: 'export type CommandSurface = \'tui\' | \'acp\' | (string & {});',
|
||||
},
|
||||
{
|
||||
name: 'CompactAgentContext',
|
||||
declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}',
|
||||
@@ -1201,10 +1185,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'CreateGoalRequest',
|
||||
declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreateGoalSpec',
|
||||
declaration: 'export interface CreateGoalSpec {\n readonly objective: string;\n readonly maxGoalRounds: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreateSessionOptions',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}',
|
||||
@@ -1305,13 +1285,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'GoalActivation',
|
||||
declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';',
|
||||
},
|
||||
{
|
||||
name: 'GoalBlockReason',
|
||||
declaration: 'export interface GoalBlockReason {\n readonly code: string;\n readonly message: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'GoalId',
|
||||
declaration: 'export type GoalId = Branded<\'GoalId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'GoalPhase',
|
||||
declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'usage-limited\' | \'budget-limited\' | \'complete\';',
|
||||
declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'complete\';',
|
||||
},
|
||||
{
|
||||
name: 'GoalRef',
|
||||
@@ -1319,7 +1303,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'GoalSnapshot',
|
||||
declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly maxGoalRounds: number;\n}',
|
||||
declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly blockedReason?: GoalBlockReason;\n readonly maxGoalRounds: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'GoalView',
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('dsh-acp-demo composition', () => {
|
||||
sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(ctx.commands.find(handle.agent, 'acp', 'goal')).toBeUndefined()
|
||||
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
|
||||
await handle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -121,14 +121,16 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => {
|
||||
const ctx = await mount({
|
||||
workspaceContext: false,
|
||||
agents: [{ id: SessionId('configured-goal'), provider: 'mock', model: 'mock' }],
|
||||
goals: {
|
||||
domain: { defaultMaxGoalRounds: 17 },
|
||||
tool: { blockedAfterConsecutiveRounds: 5 },
|
||||
},
|
||||
})
|
||||
expect(ctx.goals.resolveCreate({ objective: 'configured' })).toEqual({
|
||||
objective: 'configured',
|
||||
maxGoalRounds: 17,
|
||||
const agent = ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('configured goal test has no live agent')
|
||||
expect(ctx.goals.create(agent, { objective: 'configured' })).toMatchObject({
|
||||
objective: 'configured', maxGoalRounds: 17,
|
||||
})
|
||||
expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name))
|
||||
.toEqual(['create_goal', 'get_goal', 'update_goal'])
|
||||
@@ -199,11 +201,16 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
|
||||
it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => {
|
||||
const ctx = new Context()
|
||||
agentCore.apply(ctx, { workspaceContext: false, goals: {} })
|
||||
agentCore.apply(ctx, {
|
||||
workspaceContext: false,
|
||||
agents: [{ id: SessionId('defaulted-goal'), provider: 'mock', model: 'mock' }],
|
||||
goals: {},
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.goals.resolveCreate({ objective: 'defaulted' })).toEqual({
|
||||
objective: 'defaulted',
|
||||
maxGoalRounds: 256,
|
||||
const agent = ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('default goal test has no live agent')
|
||||
expect(ctx.goals.create(agent, { objective: 'defaulted' })).toMatchObject({
|
||||
objective: 'defaulted', maxGoalRounds: 256,
|
||||
})
|
||||
expect(ctx.tools.get('get_goal')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('dsh-stdio-demo app', () => {
|
||||
expect(agent?.id).toBe(agent?.session.id)
|
||||
expect(agent?.id).toMatch(/^main-session-/)
|
||||
expect(agent?.session.header.cwd).toBe(process.cwd())
|
||||
expect(ctx.commands.find(agent!, 'tui', 'goal')).toBeUndefined()
|
||||
expect(ctx.commands.find(agent!, 'goal')).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# @deepseek-ai/dsh-command-goal
|
||||
|
||||
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md); TUI and ACP discover and execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
|
||||
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI and ACP execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
|
||||
|
||||
## Command contract
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; show usage when no goal exists. |
|
||||
| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; a blocked goal also shows its policy code and explanation, while no goal shows usage. |
|
||||
| `/goal <objective>` | Create and arm a goal, or replace a completed goal with a fresh identity. An unfinished goal is never replaced without an explicit clear. |
|
||||
| `/goal edit <objective>` | Edit the current objective without changing its phase or activation. Editing a completed goal creates a fresh active goal. |
|
||||
| `/goal pause` | Pause an active goal and disarm continuation. |
|
||||
|
||||
@@ -48,8 +48,6 @@ function phaseLabel(phase: GoalPhase): string {
|
||||
case 'active': return 'active'
|
||||
case 'paused': return 'paused'
|
||||
case 'blocked': return 'blocked'
|
||||
case 'usage-limited': return 'usage limited'
|
||||
case 'budget-limited': return 'limited by round budget'
|
||||
case 'complete': return 'complete'
|
||||
/* v8 ignore next 2 -- GoalPhase is closed and every member is handled above */
|
||||
default: return assertNever(phase, 'goal phase')
|
||||
@@ -66,10 +64,7 @@ function commandHint(goal: GoalView): string {
|
||||
switch (goal.phase) {
|
||||
case 'paused':
|
||||
case 'blocked':
|
||||
case 'usage-limited':
|
||||
return '/goal edit <objective>, /goal resume, /goal clear'
|
||||
case 'budget-limited':
|
||||
return '/goal edit <objective>, /goal clear; after the agent raises the round cap, /goal resume'
|
||||
case 'complete':
|
||||
return '/goal <objective>, /goal clear'
|
||||
/* v8 ignore next 2 -- the active branch and every non-active phase are handled above */
|
||||
@@ -79,11 +74,16 @@ function commandHint(goal: GoalView): string {
|
||||
|
||||
/** Render direct UI output without exposing compare-and-set internals. */
|
||||
function renderGoal(title: string, goal: GoalView): CommandResult {
|
||||
const reason = goal.phase === 'blocked' ? goal.blockedReason : undefined
|
||||
/* v8 ignore next -- durable replay guarantees every blocked goal carries its validated reason */
|
||||
if (goal.phase === 'blocked' && reason === undefined) throw new TypeError('blocked goal is missing its reason')
|
||||
const blocker = reason === undefined ? [] : [`Blocker: ${reason.code}: ${reason.message}`]
|
||||
return {
|
||||
kind: 'success',
|
||||
text: [
|
||||
title,
|
||||
`Status: ${phaseLabel(goal.phase)}`,
|
||||
...blocker,
|
||||
`Objective: ${goal.objective}`,
|
||||
`Rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`,
|
||||
`Activation: ${goal.activation}`,
|
||||
@@ -159,7 +159,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the Codex-shaped `/goal` human command on TUI and ACP surfaces. */
|
||||
/** Register the Codex-shaped `/goal` command for every composed command adapter. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.commands.register({
|
||||
name: 'goal',
|
||||
|
||||
@@ -74,7 +74,6 @@ async function harness(): Promise<Harness> {
|
||||
async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
|
||||
const result = await test.ctx.commands.execute(
|
||||
test.agent,
|
||||
'tui',
|
||||
`/goal${suffix}`,
|
||||
new AbortController().signal,
|
||||
)
|
||||
@@ -87,20 +86,8 @@ function ref(goal: NonNullable<ReturnType<GoalService['get']>>): GoalRef {
|
||||
return { id: goal.id, revision: goal.revision }
|
||||
}
|
||||
|
||||
/** Append one admitted goal round for budget-limited presentation coverage. */
|
||||
function appendRound(test: Harness, goal: NonNullable<ReturnType<GoalService['get']>>): void {
|
||||
const source = { kind: 'goal', goalId: goal.id, revision: goal.revision, round: 1 } as const
|
||||
const turn = nextTurn(test.session)
|
||||
test.session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
test.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'goal round' }],
|
||||
source,
|
||||
}, { surfaceOp: 'append' })
|
||||
test.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
describe('@deepseek-ai/dsh-command-goal registration', () => {
|
||||
it('registers one global TUI/ACP command with Loader-safe exports and disposes it', async () => {
|
||||
it('registers one global command with Loader-safe exports and disposes it', async () => {
|
||||
const test = await harness()
|
||||
expect(commandGoal.name).toBe('command-goal')
|
||||
expect(commandGoal.inject).toEqual(['commands', 'goals'])
|
||||
@@ -108,16 +95,15 @@ describe('@deepseek-ai/dsh-command-goal registration', () => {
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(commandGoal)).toBe(commandGoal)
|
||||
|
||||
expect(test.ctx.commands.list(test.agent, 'tui')).toContainEqual({
|
||||
expect(test.ctx.commands.list(test.agent)).toContainEqual({
|
||||
name: 'goal',
|
||||
description: 'set or view the goal for a long-running task',
|
||||
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
|
||||
surfaces: ['tui', 'acp'],
|
||||
})
|
||||
expect(test.ctx.commands.find(test.agent, 'acp', 'goal')).toBeDefined()
|
||||
expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
|
||||
|
||||
await test.plugin.dispose()
|
||||
expect(test.ctx.commands.find(test.agent, 'tui', 'goal')).toBeUndefined()
|
||||
expect(test.ctx.commands.find(test.agent, 'goal')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -227,22 +213,16 @@ describe('/goal human command', () => {
|
||||
expect((await run(test)).text).toContain('Status: paused')
|
||||
|
||||
goal = test.ctx.goals.resume(test.agent, ref(goal))
|
||||
goal = test.ctx.goals.block(test.agent, ref(goal))
|
||||
expect((await run(test)).text).toContain('Status: blocked')
|
||||
goal = test.ctx.goals.block(test.agent, ref(goal), {
|
||||
code: 'upstream-unavailable',
|
||||
message: 'Provider unavailable',
|
||||
})
|
||||
const blocked = await run(test)
|
||||
expect(blocked.text).toContain('Status: blocked')
|
||||
expect(blocked.text).toContain('Blocker: upstream-unavailable: Provider unavailable')
|
||||
|
||||
goal = test.ctx.goals.resume(test.agent, ref(goal))
|
||||
goal = test.ctx.goals.markUsageLimited(test.agent, ref(goal))
|
||||
expect((await run(test)).text).toContain('Status: usage limited')
|
||||
|
||||
goal = test.ctx.goals.resume(test.agent, ref(goal))
|
||||
appendRound(test, goal)
|
||||
goal = test.ctx.goals.get(test.agent)!
|
||||
goal = test.ctx.goals.markBudgetLimited(test.agent, ref(goal))
|
||||
const limited = await run(test)
|
||||
expect(limited.text).toContain('Status: limited by round budget')
|
||||
expect(limited.text).toContain('after the agent raises the round cap, /goal resume')
|
||||
|
||||
goal = test.ctx.goals.complete(test.agent, ref(goal))
|
||||
test.ctx.goals.complete(test.agent, ref(goal))
|
||||
const complete = await run(test)
|
||||
expect(complete.text).toContain('Status: complete')
|
||||
expect(complete.text).toContain('Commands: /goal <objective>, /goal clear')
|
||||
|
||||
@@ -29,11 +29,11 @@ The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, t
|
||||
|
||||
| Durable turn outcome | Goal action | Automatic retry |
|
||||
|---|---|---|
|
||||
| `completed` with goal still active and armed | admit the next round, or mark `budget-limited` at the cap | yes |
|
||||
| `completed` with goal still active and armed | admit the next round, or block with code `round-limit` at the cap | yes |
|
||||
| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no |
|
||||
| cancellation with no goal-round attempt | keep durable phase; disarm activation | no |
|
||||
| `error` with `RATE_LIMIT` | `usage-limited` | no |
|
||||
| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` | no |
|
||||
| `error` with `RATE_LIMIT` | `blocked` with code `usage-limited` | no |
|
||||
| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` with a diagnostic code and message | no |
|
||||
| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no |
|
||||
|
||||
A goal mutation made during its round supersedes settlement of the older revision. Completion, pause, blocking, and edits therefore remain authoritative even if the physical turn closes afterward. No abnormal result is retried automatically.
|
||||
@@ -67,5 +67,5 @@ Append-only within an epoch: each admitted round extends the existing conversati
|
||||
- **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred.
|
||||
- **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer.
|
||||
- **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts.
|
||||
- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; `RATE_LIMIT` only maps an observed provider stop into `usage-limited`.
|
||||
- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; `RATE_LIMIT` only maps an observed provider stop into the blocked reason code `usage-limited`.
|
||||
- **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy.
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -143,11 +143,8 @@ export function apply(ctx: Context): void {
|
||||
case 'pause':
|
||||
ctx.goals.pause(state.agent, ref)
|
||||
return
|
||||
case 'usage-limited':
|
||||
ctx.goals.markUsageLimited(state.agent, ref)
|
||||
return
|
||||
case 'blocked':
|
||||
ctx.goals.block(state.agent, ref)
|
||||
ctx.goals.block(state.agent, ref, { code: outcome.code, message: outcome.message })
|
||||
return
|
||||
case 'disarm':
|
||||
ctx.goals.disarm(state.agent)
|
||||
@@ -190,7 +187,7 @@ export function apply(ctx: Context): void {
|
||||
if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision
|
||||
&& goal.phase === 'active' && goal.activation === 'armed') {
|
||||
const outcome = attempt.phase === 'queued' && attempt.rejectedReason !== undefined && !attempt.stale
|
||||
? { kind: 'blocked', reason: 'rejected', detail: attempt.rejectedReason } as const
|
||||
? { kind: 'blocked', code: 'prompt-rejected', message: attempt.rejectedReason } as const
|
||||
: classifyGoalRound(attempt.reason, durable)
|
||||
if (!attempt.stale) applyOutcome(state, goal, outcome)
|
||||
}
|
||||
@@ -200,7 +197,10 @@ export function apply(ctx: Context): void {
|
||||
const goal = currentGoal(state)
|
||||
if (goal === undefined || goal.phase !== 'active' || goal.activation !== 'armed') return
|
||||
if (goal.roundsStarted >= goal.maxGoalRounds) {
|
||||
ctx.goals.markBudgetLimited(agent, goalRef(goal))
|
||||
ctx.goals.block(agent, goalRef(goal), {
|
||||
code: 'round-limit',
|
||||
message: `Goal reached its configured limit of ${goal.maxGoalRounds} rounds.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -228,7 +228,10 @@ export function apply(ctx: Context): void {
|
||||
const latest = currentGoal(state)
|
||||
if (latest !== undefined && latest.id === goal.id && latest.revision === goal.revision
|
||||
&& latest.phase === 'active' && latest.activation === 'armed') {
|
||||
ctx.goals.block(agent, goalRef(latest))
|
||||
ctx.goals.block(agent, goalRef(latest), {
|
||||
code: 'queue-failed',
|
||||
message: `Could not queue goal round ${round}: ${renderThrown(error)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
export type GoalRoundOutcome =
|
||||
| { readonly kind: 'continue' }
|
||||
| { readonly kind: 'pause'; readonly reason: string }
|
||||
| { readonly kind: 'usage-limited'; readonly message: string }
|
||||
| {
|
||||
readonly kind: 'blocked'
|
||||
readonly reason: 'error' | 'max-tokens' | 'rejected' | 'unknown'
|
||||
readonly detail: string
|
||||
readonly code: 'usage-limited' | 'turn-error' | 'max-tokens' | 'prompt-rejected' | 'unknown-turn-outcome'
|
||||
readonly message: string
|
||||
}
|
||||
| { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' }
|
||||
|
||||
@@ -30,12 +29,12 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal
|
||||
return { kind: 'pause', reason: reason.reason ?? 'cancelled' }
|
||||
case 'error':
|
||||
return reason.code === 'RATE_LIMIT'
|
||||
? { kind: 'usage-limited', message: reason.message }
|
||||
: { kind: 'blocked', reason: 'error', detail: reason.message }
|
||||
? { kind: 'blocked', code: 'usage-limited', message: reason.message }
|
||||
: { kind: 'blocked', code: 'turn-error', message: reason.message }
|
||||
case 'max-tokens':
|
||||
return { kind: 'blocked', reason: 'max-tokens', detail: 'model output reached max tokens' }
|
||||
return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }
|
||||
case 'rejected':
|
||||
return { kind: 'blocked', reason: 'rejected', detail: reason.reason }
|
||||
return { kind: 'blocked', code: 'prompt-rejected', message: reason.reason }
|
||||
case 'disposed':
|
||||
return { kind: 'disarm', reason: 'disposed' }
|
||||
case 'interrupted':
|
||||
@@ -43,6 +42,10 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal
|
||||
// TurnEndReason is merge-extensible. An unknown producer cannot opt into
|
||||
// automatic retry merely by adding a tag; stop for inspection instead.
|
||||
default:
|
||||
return { kind: 'blocked', reason: 'unknown', detail: `unknown turn outcome: ${extensibleReason.kind}` }
|
||||
return {
|
||||
kind: 'blocked',
|
||||
code: 'unknown-turn-outcome',
|
||||
message: `unknown turn outcome: ${extensibleReason.kind}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { foldGoal } from '@deepseek-ai/dsh-goal'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
const PROCESS_TIMEOUT_MS = 30_000
|
||||
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
|
||||
child = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
/** Recursively locate persistence JSONL files in one temporary root. */
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) return jsonlFiles(path)
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
||||
}))
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
/** Run the complete deterministic human-turn plus two-round composition. */
|
||||
async function runComposition(): Promise<{ stdout: string; stderr: string }> {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'goal-session-e2e-'))
|
||||
const cwd = workdir
|
||||
return new Promise((resolve, reject) => {
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: [configPath],
|
||||
tsconfigPath: repoTsconfig,
|
||||
exposeInternals: true,
|
||||
env: {
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
})
|
||||
const proc = spawn(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...launch.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let inputClosed = false
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!inputClosed && stdout.includes('ROUND TWO COMPLETE') && stdout.includes('\n> ')) {
|
||||
inputClosed = true
|
||||
proc.stdin.end()
|
||||
}
|
||||
})
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL')
|
||||
reject(new Error(
|
||||
`goal-session e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
))
|
||||
}, PROCESS_TIMEOUT_MS)
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) resolve({ stdout, stderr })
|
||||
else reject(new Error(`goal-session e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
})
|
||||
proc.on('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
proc.stdin.write('start\n')
|
||||
})
|
||||
}
|
||||
|
||||
describe('same-session goal rounds through a real Loader, app, and stdio process', () => {
|
||||
it('persists two exact rounds and stops the completion turn without another request', async () => {
|
||||
const { stdout, stderr } = await runComposition()
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stdout).toContain('goal-session e2e ready.')
|
||||
expect(stdout).toContain('GOAL CREATED')
|
||||
expect(stdout).toContain('ROUND ONE')
|
||||
expect(stdout).toContain('ROUND TWO COMPLETE')
|
||||
|
||||
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
|
||||
const calls = events.filter(event => event.type === 'tool/call')
|
||||
expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal'])
|
||||
expect(events.filter(event => event.type === 'step/start')).toHaveLength(5)
|
||||
expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true)
|
||||
|
||||
const rounds = events.filter(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal')
|
||||
expect(rounds).toHaveLength(2)
|
||||
const roundNumbers: number[] = []
|
||||
const revisions: number[] = []
|
||||
const prompts: string[] = []
|
||||
for (const event of events) {
|
||||
if (event.type !== 'user/message' || event.data.source.kind !== 'goal') continue
|
||||
roundNumbers.push(event.data.source.round)
|
||||
revisions.push(event.data.source.revision)
|
||||
prompts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
|
||||
}
|
||||
expect(roundNumbers).toEqual([1, 2])
|
||||
expect(revisions).toEqual([1, 1])
|
||||
expect(prompts[0]).toContain('Round: 1/2')
|
||||
expect(prompts[1]).toContain('Round: 2/2')
|
||||
|
||||
expect(foldGoal(events)).toMatchObject({
|
||||
goal: {
|
||||
objective: 'Complete two deterministic same-session rounds',
|
||||
phase: 'complete',
|
||||
revision: 2,
|
||||
maxGoalRounds: 2,
|
||||
},
|
||||
roundsStarted: 2,
|
||||
})
|
||||
}, TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -126,18 +126,18 @@ describe('goal-round outcome policy', () => {
|
||||
[{ kind: 'aborted', reason: 'operator stopped' }, true, { kind: 'pause', reason: 'operator stopped' }],
|
||||
[{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }],
|
||||
[{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true,
|
||||
{ kind: 'usage-limited', message: 'slow down' }],
|
||||
{ kind: 'blocked', code: 'usage-limited', message: 'slow down' }],
|
||||
[{ kind: 'error', step: 1, message: 'broken' }, true,
|
||||
{ kind: 'blocked', reason: 'error', detail: 'broken' }],
|
||||
{ kind: 'blocked', code: 'turn-error', message: 'broken' }],
|
||||
[{ kind: 'max-tokens' }, true,
|
||||
{ kind: 'blocked', reason: 'max-tokens', detail: 'model output reached max tokens' }],
|
||||
{ kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }],
|
||||
[{ kind: 'rejected', reason: 'policy' }, true,
|
||||
{ kind: 'blocked', reason: 'rejected', detail: 'policy' }],
|
||||
{ kind: 'blocked', code: 'prompt-rejected', message: 'policy' }],
|
||||
[{ kind: 'disposed' }, true, { kind: 'disarm', reason: 'disposed' }],
|
||||
[{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }],
|
||||
[{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }],
|
||||
[{ kind: 'future-outcome' } as unknown as TurnEndReason, true,
|
||||
{ kind: 'blocked', reason: 'unknown', detail: 'unknown turn outcome: future-outcome' }],
|
||||
{ kind: 'blocked', code: 'unknown-turn-outcome', message: 'unknown turn outcome: future-outcome' }],
|
||||
] as const)('maps %j without abnormal automatic retry', (reason, durable, expected) => {
|
||||
expect(goalSession.classifyGoalRound(reason, durable)).toEqual(expected)
|
||||
})
|
||||
@@ -187,9 +187,13 @@ describe('same-session goal driving', () => {
|
||||
const test = await harness([textResponse('round one'), textResponse('round two')])
|
||||
const created = test.ctx.goals.create(test.agent, { objective: 'finish twice', maxGoalRounds: 2 })
|
||||
|
||||
const final = await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited')
|
||||
const final = await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(final).toMatchObject({ id: created.id, roundsStarted: 2, activation: 'disarmed' })
|
||||
expect(final?.blockedReason).toEqual({
|
||||
code: 'round-limit',
|
||||
message: 'Goal reached its configured limit of 2 rounds.',
|
||||
})
|
||||
expect(test.adapter.requests).toHaveLength(2)
|
||||
const rounds: number[] = []
|
||||
for (const event of test.agent.session.events) {
|
||||
@@ -219,21 +223,22 @@ describe('same-session goal driving', () => {
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
|
||||
ctx.goals.resume(agent, created)
|
||||
await waitForGoal(ctx, agent, goal => goal?.phase === 'budget-limited')
|
||||
await waitForGoal(ctx, agent, goal => goal?.phase === 'blocked')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['rate limit', Object.assign(new Error('slow down'), { code: 'RATE_LIMIT' }), 'usage-limited'],
|
||||
['request error', new Error('provider broke'), 'blocked'],
|
||||
['max tokens', maxTokensResponse('unfinished'), 'blocked'],
|
||||
] as const)('stops after a %s without an automatic retry', async (_label, response, phase) => {
|
||||
['request error', new Error('provider broke'), 'turn-error'],
|
||||
['max tokens', maxTokensResponse('unfinished'), 'max-tokens'],
|
||||
] as const)('stops after a %s without an automatic retry', async (_label, response, code) => {
|
||||
const test = await harness([response])
|
||||
test.ctx.goals.create(test.agent, { objective: 'stop safely', maxGoalRounds: 8 })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === phase)
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' })
|
||||
expect(goal?.blockedReason?.code).toBe(code)
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -247,6 +252,7 @@ describe('same-session goal driving', () => {
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal?.roundsStarted).toBe(0)
|
||||
expect(goal?.blockedReason).toEqual({ code: 'prompt-rejected', message: 'deployment policy' })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
|
||||
&& event.data.reason === 'deployment policy')).toBe(true)
|
||||
@@ -305,7 +311,7 @@ describe('same-session goal driving', () => {
|
||||
test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 })
|
||||
test.agent.send([{ type: 'text', text: 'human goes first' }])
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited')
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(test.adapter.requests).toHaveLength(2)
|
||||
expect(requestText(test.adapter.requests[0]!)).toContain('human goes first')
|
||||
@@ -323,7 +329,7 @@ describe('same-session goal driving', () => {
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 })
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited')
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(test.adapter.requests).toHaveLength(2)
|
||||
expect(requestText(test.adapter.requests[0]!)).toContain('human joined the pending batch')
|
||||
@@ -343,7 +349,7 @@ describe('same-session goal driving', () => {
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'old objective', maxGoalRounds: 1 })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'budget-limited')
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 })
|
||||
const blocked = test.agent.session.events.find(event => event.type === 'prompt/blocked')
|
||||
@@ -370,7 +376,7 @@ describe('same-session goal driving', () => {
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'edit during admission', maxGoalRounds: 1 })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'budget-limited')
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal).toMatchObject({ objective: 'edited downstream', roundsStarted: 1 })
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
@@ -442,6 +448,10 @@ describe('same-session goal driving', () => {
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
|
||||
expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' })
|
||||
expect(goal?.blockedReason).toEqual({
|
||||
code: 'queue-failed',
|
||||
message: 'Could not queue goal round 1: queue rejected',
|
||||
})
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -519,7 +529,7 @@ describe('same-session goal driving', () => {
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'retry stale admission', maxGoalRounds: 1 })
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited')
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
|
||||
@@ -669,7 +679,7 @@ describe('same-session goal driving', () => {
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
|
||||
test.ctx.goals.resume(test.agent, created)
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited')
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ Event-sourced same-session goal state. The service retains one current completio
|
||||
defaultMaxGoalRounds: 256
|
||||
```
|
||||
|
||||
`defaultMaxGoalRounds` must be a positive safe integer. `resolveCreate()` materializes this deployment default before `create()` commits a goal; a request-level value overrides it.
|
||||
`defaultMaxGoalRounds` must be a positive safe integer. `create()` materializes this deployment default internally before committing a goal; a request-level value overrides it.
|
||||
|
||||
## Service contract
|
||||
|
||||
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). `disarm()` is the lifecycle-only exception: it removes process-local continuation authority without writing a revision or emitting a mutation.
|
||||
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is internal. `disarm()` is the lifecycle-only exception: it removes process-local continuation authority without writing a revision or emitting a mutation.
|
||||
|
||||
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; an active armed goal rejects the redundant operation.
|
||||
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
|
||||
|
||||
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
|
||||
|
||||
@@ -35,7 +35,7 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log.
|
||||
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { renderGoalChange } from './render.ts'
|
||||
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
|
||||
import type {
|
||||
FoldedGoal,
|
||||
GoalBlockReason,
|
||||
GoalChangeMeta,
|
||||
GoalClearChangeMeta,
|
||||
GoalMessageSource,
|
||||
@@ -25,17 +26,8 @@ const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Se
|
||||
'resume',
|
||||
'complete',
|
||||
'block',
|
||||
'mark-usage-limited',
|
||||
'mark-budget-limited',
|
||||
])
|
||||
const PHASES: ReadonlySet<GoalPhase> = new Set([
|
||||
'active',
|
||||
'paused',
|
||||
'blocked',
|
||||
'usage-limited',
|
||||
'budget-limited',
|
||||
'complete',
|
||||
])
|
||||
const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])
|
||||
|
||||
/** Mutable accumulator kept private to the pure fold. */
|
||||
export interface GoalFoldState {
|
||||
@@ -83,13 +75,24 @@ function nonNegativeInteger(value: unknown, field: string): number {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Decode one canonical blocker explanation. */
|
||||
function decodeBlockReason(value: unknown): GoalBlockReason {
|
||||
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') {
|
||||
throw new Error('goal change goal.blockedReason has an invalid shape')
|
||||
}
|
||||
if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) {
|
||||
throw new Error('goal change goal.blockedReason.code must be lower-kebab-case')
|
||||
}
|
||||
if (typeof value['message'] !== 'string' || value['message'].trim().length === 0
|
||||
|| value['message'] !== value['message'].trim()) {
|
||||
throw new Error('goal change goal.blockedReason.message must be non-empty and normalized')
|
||||
}
|
||||
return { code: value['code'], message: value['message'] }
|
||||
}
|
||||
|
||||
/** Decode and validate one snapshot. */
|
||||
function decodeSnapshot(value: unknown): GoalSnapshot {
|
||||
if (!isRecord(value)) throw new Error('goal change goal must be a record')
|
||||
const keys = Object.keys(value).sort()
|
||||
if (keys.join(',') !== 'id,maxGoalRounds,objective,phase,revision') {
|
||||
throw new Error('goal change goal has an invalid shape')
|
||||
}
|
||||
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
|
||||
throw new Error('goal change goal.id must be a non-empty string')
|
||||
}
|
||||
@@ -100,12 +103,20 @@ function decodeSnapshot(value: unknown): GoalSnapshot {
|
||||
if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) {
|
||||
throw new Error('goal change goal.phase is invalid')
|
||||
}
|
||||
const phase = value['phase'] as GoalPhase
|
||||
const expectedKeys = phase === 'blocked'
|
||||
? 'blockedReason,id,maxGoalRounds,objective,phase,revision'
|
||||
: 'id,maxGoalRounds,objective,phase,revision'
|
||||
if (Object.keys(value).sort().join(',') !== expectedKeys) {
|
||||
throw new Error('goal change goal has an invalid shape')
|
||||
}
|
||||
return {
|
||||
id: GoalId(value['id']),
|
||||
revision: positiveInteger(value['revision'], 'goal.revision'),
|
||||
objective: value['objective'],
|
||||
phase: value['phase'] as GoalPhase,
|
||||
phase,
|
||||
maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'),
|
||||
...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +219,10 @@ function validateSnapshotTransition(
|
||||
}
|
||||
switch (change.operation) {
|
||||
case 'edit':
|
||||
if (next.phase !== current.phase) throw new Error('goal edit cannot change phase')
|
||||
if (next.phase !== current.phase
|
||||
|| JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {
|
||||
throw new Error('goal edit cannot change phase or blocked reason')
|
||||
}
|
||||
break
|
||||
case 'pause':
|
||||
requireSameDefinition(current, next, change.operation)
|
||||
@@ -220,8 +234,6 @@ function validateSnapshotTransition(
|
||||
'active',
|
||||
'paused',
|
||||
'blocked',
|
||||
'usage-limited',
|
||||
'budget-limited',
|
||||
])
|
||||
if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) {
|
||||
throw new Error('goal resume has an invalid phase transition or exhausted round budget')
|
||||
@@ -236,19 +248,6 @@ function validateSnapshotTransition(
|
||||
requireSameDefinition(current, next, change.operation)
|
||||
if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition')
|
||||
break
|
||||
case 'mark-usage-limited':
|
||||
requireSameDefinition(current, next, change.operation)
|
||||
if (current.phase !== 'active' || next.phase !== 'usage-limited') {
|
||||
throw new Error('goal mark-usage-limited has an invalid phase transition')
|
||||
}
|
||||
break
|
||||
case 'mark-budget-limited':
|
||||
requireSameDefinition(current, next, change.operation)
|
||||
if (current.phase !== 'active' || next.phase !== 'budget-limited'
|
||||
|| state.roundsStarted < next.maxGoalRounds) {
|
||||
throw new Error('goal mark-budget-limited has an invalid phase transition or remaining round budget')
|
||||
}
|
||||
break
|
||||
/* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */
|
||||
case 'create':
|
||||
throw new Error('goal create cannot be validated as a current-goal transition')
|
||||
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
} from './runtime.ts'
|
||||
import type {
|
||||
CreateGoalRequest,
|
||||
CreateGoalSpec,
|
||||
EditGoalRequest,
|
||||
GoalActivation,
|
||||
GoalBlockReason,
|
||||
GoalChangeMeta,
|
||||
GoalChanged,
|
||||
GoalClearChangeMeta,
|
||||
@@ -79,6 +79,12 @@ interface GoalCache {
|
||||
readonly pending: PendingGoalChange[]
|
||||
}
|
||||
|
||||
/** Validated create input with every deployment default materialized. */
|
||||
interface ResolvedCreateGoal {
|
||||
readonly objective: string
|
||||
readonly maxGoalRounds: number
|
||||
}
|
||||
|
||||
/** Validate a caller-visible positive safe-integer round cap. */
|
||||
function resolveMaxGoalRounds(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
@@ -95,6 +101,31 @@ function resolveObjective(value: string): string {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
/** Materialize deployment defaults and validate one create request. */
|
||||
function resolveCreateGoal(request: CreateGoalRequest, defaultMaxGoalRounds: number): ResolvedCreateGoal {
|
||||
return {
|
||||
objective: resolveObjective(request.objective),
|
||||
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds),
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and detach one policy-owned blocker explanation. */
|
||||
function resolveBlockReason(reason: unknown): GoalBlockReason {
|
||||
const record = typeof reason === 'object' && reason !== null && !Array.isArray(reason)
|
||||
? reason as Record<string, unknown>
|
||||
: undefined
|
||||
const code = record?.['code']
|
||||
const message = record?.['message']
|
||||
if (typeof code !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code)
|
||||
|| typeof message !== 'string' || message.trim().length === 0) {
|
||||
throw new GoalError(
|
||||
'goal block reason requires a lower-kebab-case code and a non-empty message',
|
||||
'GOAL_INVALID_BLOCK_REASON',
|
||||
)
|
||||
}
|
||||
return { code, message: message.trim() }
|
||||
}
|
||||
|
||||
/** Compare the complete canonical payloads used for deferred reconciliation. */
|
||||
function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
@@ -121,18 +152,6 @@ export class GoalService extends Service {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize deployment defaults and validate one create request.
|
||||
* @param request - objective plus optional caller-selected round cap.
|
||||
* @returns detached, fully resolved create specification.
|
||||
*/
|
||||
resolveCreate(request: CreateGoalRequest): CreateGoalSpec {
|
||||
return {
|
||||
objective: resolveObjective(request.objective),
|
||||
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? this.resolved.defaultMaxGoalRounds),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current goal for one exact live agent.
|
||||
* @param agent - owning live agent.
|
||||
@@ -169,7 +188,7 @@ export class GoalService extends Service {
|
||||
* @returns the created live view.
|
||||
*/
|
||||
create(agent: Agent, request: CreateGoalRequest): GoalView {
|
||||
const spec = this.resolveCreate(request)
|
||||
const spec = resolveCreateGoal(request, this.resolved.defaultMaxGoalRounds)
|
||||
const cache = this.prepareMutation(agent)
|
||||
const current = cache.state.goal
|
||||
if (current !== undefined && current.phase !== 'complete') {
|
||||
@@ -228,7 +247,7 @@ export class GoalService extends Service {
|
||||
resume(agent: Agent, ref: GoalRef): GoalView {
|
||||
const cache = this.prepareMutation(agent)
|
||||
const current = this.expectCurrent(cache, ref)
|
||||
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited']
|
||||
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked']
|
||||
if (!resumable.includes(current.phase)) {
|
||||
throw this.transitionError(current, 'resume', resumable)
|
||||
}
|
||||
@@ -255,7 +274,7 @@ export class GoalService extends Service {
|
||||
agent,
|
||||
ref,
|
||||
'complete',
|
||||
['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'],
|
||||
['active', 'paused', 'blocked'],
|
||||
'complete',
|
||||
'disarmed',
|
||||
)
|
||||
@@ -265,45 +284,20 @@ export class GoalService extends Service {
|
||||
* Mark an active goal blocked and disarm it.
|
||||
* @param agent - owning live agent.
|
||||
* @param ref - expected current revision.
|
||||
* @returns the blocked view.
|
||||
* @param reason - policy-owned stable code and human-readable explanation.
|
||||
* @returns the blocked view with its durable reason.
|
||||
*/
|
||||
block(agent: Agent, ref: GoalRef): GoalView {
|
||||
return this.transition(agent, ref, 'block', ['active'], 'blocked', 'disarmed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an active goal stopped by an external usage limit.
|
||||
* @param agent - owning live agent.
|
||||
* @param ref - expected current revision.
|
||||
* @returns the usage-limited view.
|
||||
*/
|
||||
markUsageLimited(agent: Agent, ref: GoalRef): GoalView {
|
||||
return this.transition(agent, ref, 'mark-usage-limited', ['active'], 'usage-limited', 'disarmed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an active goal stopped at its configured round cap.
|
||||
* @param agent - owning live agent.
|
||||
* @param ref - expected current revision.
|
||||
* @returns the budget-limited view.
|
||||
*/
|
||||
markBudgetLimited(agent: Agent, ref: GoalRef): GoalView {
|
||||
block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView {
|
||||
const cache = this.prepareMutation(agent)
|
||||
const current = this.expectCurrent(cache, ref)
|
||||
if (current.phase !== 'active') {
|
||||
throw this.transitionError(current, 'mark-budget-limited', ['active'])
|
||||
}
|
||||
if (cache.state.roundsStarted < current.maxGoalRounds) {
|
||||
throw new GoalError(
|
||||
`goal "${current.id}" has started ${cache.state.roundsStarted}/${current.maxGoalRounds} rounds`,
|
||||
'GOAL_INVALID_TRANSITION',
|
||||
)
|
||||
throw this.transitionError(current, 'block', ['active'])
|
||||
}
|
||||
return this.commitCurrent(
|
||||
agent,
|
||||
cache,
|
||||
'mark-budget-limited',
|
||||
this.withPhase(current, 'budget-limited'),
|
||||
'block',
|
||||
{ ...this.withPhase(current, 'blocked'), blockedReason: resolveBlockReason(reason) },
|
||||
'disarmed',
|
||||
)
|
||||
}
|
||||
@@ -399,7 +393,13 @@ export class GoalService extends Service {
|
||||
|
||||
/** Build a new revision with one replacement phase. */
|
||||
private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot {
|
||||
return { ...current, revision: current.revision + 1, phase }
|
||||
return {
|
||||
id: current.id,
|
||||
revision: current.revision + 1,
|
||||
objective: current.objective,
|
||||
phase,
|
||||
maxGoalRounds: current.maxGoalRounds,
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared validated phase transition. */
|
||||
|
||||
@@ -22,16 +22,24 @@ export type GoalPhase =
|
||||
| 'active'
|
||||
| 'paused'
|
||||
| 'blocked'
|
||||
| 'usage-limited'
|
||||
| 'budget-limited'
|
||||
| 'complete'
|
||||
|
||||
/** Machine-routable and human-readable explanation for a blocked goal. */
|
||||
export interface GoalBlockReason {
|
||||
/** Stable lower-kebab-case classification chosen by the blocking policy. */
|
||||
readonly code: string
|
||||
/** Non-empty explanation shown to humans and models. */
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Full durable state written by every non-clear goal mutation. */
|
||||
export interface GoalSnapshot extends GoalRef {
|
||||
/** Human-requested completion objective. */
|
||||
readonly objective: string
|
||||
/** Durable lifecycle phase. */
|
||||
readonly phase: GoalPhase
|
||||
/** Present exactly while `phase` is `blocked`. */
|
||||
readonly blockedReason?: GoalBlockReason
|
||||
/** Total admitted goal-round cap. */
|
||||
readonly maxGoalRounds: number
|
||||
}
|
||||
@@ -59,8 +67,6 @@ export type GoalOperation =
|
||||
| 'resume'
|
||||
| 'complete'
|
||||
| 'block'
|
||||
| 'mark-usage-limited'
|
||||
| 'mark-budget-limited'
|
||||
| 'clear'
|
||||
|
||||
/** Full-snapshot goal mutation retained in a model-visible context event. */
|
||||
@@ -121,12 +127,6 @@ export interface CreateGoalRequest {
|
||||
readonly maxGoalRounds?: number
|
||||
}
|
||||
|
||||
/** Validated create input with every deployment default materialized. */
|
||||
export interface CreateGoalSpec {
|
||||
readonly objective: string
|
||||
readonly maxGoalRounds: number
|
||||
}
|
||||
|
||||
/** Fields changed by an edit; at least one must be present. */
|
||||
export interface EditGoalRequest {
|
||||
readonly objective?: string
|
||||
@@ -149,6 +149,7 @@ export type GoalErrorCode =
|
||||
| 'GOAL_STALE_REVISION'
|
||||
| 'GOAL_INVALID_OBJECTIVE'
|
||||
| 'GOAL_INVALID_MAX_ROUNDS'
|
||||
| 'GOAL_INVALID_BLOCK_REASON'
|
||||
| 'GOAL_INVALID_EDIT'
|
||||
| 'GOAL_INVALID_TRANSITION'
|
||||
|
||||
|
||||
@@ -111,17 +111,13 @@ function appendRound(session: Session, ref: GoalRef, round: number): void {
|
||||
}
|
||||
|
||||
describe('GoalService creation and replay', () => {
|
||||
it('resolves the configured default and writes one balanced raw context snapshot', async () => {
|
||||
it('applies the configured default and writes one balanced raw context snapshot', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_700_000_000_000)
|
||||
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
|
||||
const seen: string[] = []
|
||||
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
|
||||
|
||||
expect(ctx.goals.resolveCreate({ objective: ' finish the feature ' })).toEqual({
|
||||
objective: 'finish the feature',
|
||||
maxGoalRounds: 17,
|
||||
})
|
||||
const goal = ctx.goals.create(agent, { objective: ' finish the feature ' })
|
||||
|
||||
expect(goal).toMatchObject({
|
||||
@@ -151,18 +147,19 @@ describe('GoalService creation and replay', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('uses 256 rounds by default and validates create input at the owning resolver', async () => {
|
||||
it('uses 256 rounds by default and validates create input inside create', async () => {
|
||||
const { ctx, agent } = await harness()
|
||||
expect(ctx.goals.resolveCreate({ objective: 'x' })).toEqual({ objective: 'x', maxGoalRounds: 256 })
|
||||
expect(() => ctx.goals.resolveCreate({ objective: ' ' })).toThrow(expect.objectContaining({
|
||||
expect(() => ctx.goals.create(agent, { objective: ' ' })).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_OBJECTIVE',
|
||||
}))
|
||||
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({
|
||||
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_MAX_ROUNDS',
|
||||
}))
|
||||
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError)
|
||||
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError)
|
||||
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1 })).toThrow(GoalError)
|
||||
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError)
|
||||
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError)
|
||||
expect(() => ctx.goals.create(agent, {
|
||||
objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1,
|
||||
})).toThrow(GoalError)
|
||||
expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256)
|
||||
})
|
||||
|
||||
@@ -170,9 +167,10 @@ describe('GoalService creation and replay', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const goals = new GoalService(ctx)
|
||||
expect(goals.resolveCreate({ objective: 'direct' })).toEqual({
|
||||
objective: 'direct',
|
||||
maxGoalRounds: 256,
|
||||
const stub = stubAgent('goal-direct-construction')
|
||||
ctx.agents.register(stub.agent)
|
||||
expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
|
||||
objective: 'direct', maxGoalRounds: 256,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -301,18 +299,19 @@ describe('GoalService mutations', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('supports pause, resume, block, usage-limit, and completion transitions', async () => {
|
||||
it('supports pause, resume, block, and completion transitions', async () => {
|
||||
const { ctx, agent } = await harness()
|
||||
let goal = ctx.goals.create(agent, { objective: 'lifecycle' })
|
||||
goal = ctx.goals.pause(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 })
|
||||
goal = ctx.goals.resume(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 })
|
||||
goal = ctx.goals.block(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'blocked', activation: 'disarmed' })
|
||||
goal = ctx.goals.resume(agent, goal)
|
||||
goal = ctx.goals.markUsageLimited(agent, goal)
|
||||
expect(goal.phase).toBe('usage-limited')
|
||||
goal = ctx.goals.block(agent, goal, { code: 'needs-input', message: 'A choice is required.' })
|
||||
expect(goal).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: { code: 'needs-input', message: 'A choice is required.' },
|
||||
activation: 'disarmed',
|
||||
})
|
||||
goal = ctx.goals.resume(agent, goal)
|
||||
goal = ctx.goals.pause(agent, goal)
|
||||
goal = ctx.goals.complete(agent, goal)
|
||||
@@ -321,15 +320,13 @@ describe('GoalService mutations', () => {
|
||||
})
|
||||
|
||||
it('allows completion from every stopped phase and replacement only after completion', async () => {
|
||||
const phases = ['paused', 'blocked', 'usage-limited'] as const
|
||||
const phases = ['paused', 'blocked'] as const
|
||||
for (const phase of phases) {
|
||||
const { ctx, agent } = await harness()
|
||||
let goal = ctx.goals.create(agent, { objective: phase })
|
||||
goal = phase === 'paused'
|
||||
? ctx.goals.pause(agent, goal)
|
||||
: phase === 'blocked'
|
||||
? ctx.goals.block(agent, goal)
|
||||
: ctx.goals.markUsageLimited(agent, goal)
|
||||
: ctx.goals.block(agent, goal, { code: 'test-blocker', message: 'Blocked for the test.' })
|
||||
const complete = ctx.goals.complete(agent, goal)
|
||||
const replacement = ctx.goals.create(agent, { objective: `after ${phase}` })
|
||||
expect(complete.phase).toBe('complete')
|
||||
@@ -347,32 +344,45 @@ describe('GoalService mutations', () => {
|
||||
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
|
||||
const paused = ctx.goals.pause(agent, goal)
|
||||
expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
|
||||
expect(() => ctx.goals.block(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
|
||||
expect(() => ctx.goals.markUsageLimited(agent, paused)).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_TRANSITION',
|
||||
}))
|
||||
expect(() => ctx.goals.markBudgetLimited(agent, paused)).toThrow(expect.objectContaining({
|
||||
expect(() => ctx.goals.block(agent, paused, {
|
||||
code: 'test-blocker', message: 'Blocked for the test.',
|
||||
})).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_TRANSITION',
|
||||
}))
|
||||
})
|
||||
|
||||
it('enforces the goal-round cap before budget limiting and resuming', async () => {
|
||||
it('records canonical blocker reasons and enforces the round cap on resume', async () => {
|
||||
const { ctx, agent, session } = await harness()
|
||||
let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 })
|
||||
for (const reason of [null, [], { code: 1, message: 'invalid code' }, { code: 'round-limit', message: 1 }]) {
|
||||
expect(() => ctx.goals.block(agent, goal, reason as never)).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_BLOCK_REASON',
|
||||
}))
|
||||
}
|
||||
expect(() => ctx.goals.block(agent, goal, {
|
||||
code: 'Not Canonical', message: 'invalid code',
|
||||
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
|
||||
expect(() => ctx.goals.block(agent, goal, {
|
||||
code: 'round-limit', message: ' ',
|
||||
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
|
||||
appendRound(session, goal, 1)
|
||||
expect(ctx.goals.get(agent)?.roundsStarted).toBe(1)
|
||||
expect(() => ctx.goals.markBudgetLimited(agent, goal)).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_TRANSITION',
|
||||
}))
|
||||
appendRound(session, goal, 2)
|
||||
goal = ctx.goals.markBudgetLimited(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'budget-limited', roundsStarted: 2, activation: 'disarmed' })
|
||||
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: ' Goal round limit reached. ' })
|
||||
expect(goal).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: { code: 'round-limit', message: 'Goal round limit reached.' },
|
||||
roundsStarted: 2,
|
||||
activation: 'disarmed',
|
||||
})
|
||||
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
|
||||
goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 })
|
||||
expect(goal.blockedReason).toEqual({ code: 'round-limit', message: 'Goal round limit reached.' })
|
||||
goal = ctx.goals.resume(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' })
|
||||
expect(goal.blockedReason).toBeUndefined()
|
||||
appendRound(session, goal, 3)
|
||||
goal = ctx.goals.markBudgetLimited(agent, goal)
|
||||
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: 'Goal round limit reached.' })
|
||||
expect(ctx.goals.complete(agent, goal).phase).toBe('complete')
|
||||
})
|
||||
|
||||
@@ -612,7 +622,16 @@ describe('goal replay validation', () => {
|
||||
return {
|
||||
...current,
|
||||
operation,
|
||||
goal: { ...current.goal, revision: current.goal.revision + 1, phase },
|
||||
goal: {
|
||||
id: current.goal.id,
|
||||
revision: current.goal.revision + 1,
|
||||
objective: current.goal.objective,
|
||||
phase,
|
||||
...phase === 'blocked'
|
||||
? { blockedReason: { code: 'test-blocker', message: 'Blocked for replay validation.' } }
|
||||
: {},
|
||||
maxGoalRounds: current.goal.maxGoalRounds,
|
||||
},
|
||||
updatedAt: current.updatedAt + 1,
|
||||
...overrides,
|
||||
}
|
||||
@@ -708,9 +727,6 @@ describe('goal replay validation', () => {
|
||||
mutation(base, 'resume', 'paused'),
|
||||
mutation(base, 'complete', 'active'),
|
||||
mutation(base, 'block', 'active'),
|
||||
mutation(base, 'mark-usage-limited', 'active'),
|
||||
mutation(base, 'mark-budget-limited', 'active'),
|
||||
mutation(base, 'mark-budget-limited', 'budget-limited'),
|
||||
]
|
||||
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
|
||||
|
||||
@@ -796,6 +812,12 @@ describe('goal replay validation', () => {
|
||||
{ ...base.goal, objective: ' ' },
|
||||
{ ...base.goal, objective: ' padded ' },
|
||||
{ ...base.goal, phase: 'unknown' },
|
||||
{ ...base.goal, blockedReason: { code: 'unexpected', message: 'Only blocked goals have reasons.' } },
|
||||
{ ...base.goal, phase: 'blocked' },
|
||||
{ ...base.goal, phase: 'blocked', blockedReason: null },
|
||||
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: 'Valid.', extra: true } },
|
||||
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'NOT_CANONICAL', message: 'Bad code.' } },
|
||||
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: ' padded ' } },
|
||||
{ ...base.goal, revision: 0 },
|
||||
{ ...base.goal, maxGoalRounds: -1 },
|
||||
]
|
||||
|
||||
@@ -4,9 +4,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
|
||||
|
||||
## Tools
|
||||
|
||||
- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, and current process-local activation.
|
||||
- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation.
|
||||
- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution.
|
||||
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`.
|
||||
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`.
|
||||
|
||||
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations.
|
||||
|
||||
@@ -18,7 +18,7 @@ Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` in
|
||||
|
||||
`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
|
||||
|
||||
Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted. Direct human authority may stop a goal immediately.
|
||||
Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -42,7 +42,7 @@ A fixed goal policy says when semantic human intent warrants creation, requires
|
||||
##### Goal policy
|
||||
|
||||
```markdown
|
||||
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked.
|
||||
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -51,7 +51,8 @@ const CREATE_DESCRIPTION =
|
||||
|
||||
const GET_DESCRIPTION =
|
||||
'Read the current same-session goal, including its exact id/revision, objective, phase, completed '
|
||||
+ 'continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.'
|
||||
+ 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. '
|
||||
+ 'Call this before updating a goal.'
|
||||
|
||||
/** Render policy guidance with its deployment-selected blocked threshold. */
|
||||
function guidance(blockedAfter: number): string {
|
||||
@@ -62,7 +63,8 @@ function guidance(blockedAfter: number): string {
|
||||
+ 'a human asks to continue or resume in any wording or language, use update_goal action '
|
||||
+ 'resume to rearm it. Mark complete only when the objective is actually achieved. Mark '
|
||||
+ `blocked only after the same blocking condition persists for at least ${blockedAfter} `
|
||||
+ 'consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked.'
|
||||
+ 'consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, '
|
||||
+ 'or useful remaining work is not blocked.'
|
||||
}
|
||||
|
||||
/** Validate config even when apply is called directly outside Loader normalization. */
|
||||
@@ -97,6 +99,7 @@ function renderGoal(goal: GoalView | undefined): string {
|
||||
phase: goal.phase,
|
||||
roundsStarted: goal.roundsStarted,
|
||||
maxGoalRounds: goal.maxGoalRounds,
|
||||
...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason },
|
||||
},
|
||||
activation: goal.activation,
|
||||
})
|
||||
@@ -183,7 +186,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
description: 'Update the exact current goal revision. edit, pause, and resume require a direct '
|
||||
+ 'top-level human request. During an automatic continuation of the current goal, complete '
|
||||
+ 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains '
|
||||
+ 'responsible for judging that the same condition persisted across those rounds.',
|
||||
+ 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.',
|
||||
parameters: {
|
||||
goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' },
|
||||
revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' },
|
||||
@@ -195,6 +198,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
},
|
||||
objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' },
|
||||
max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' },
|
||||
blocked_reason: {
|
||||
type: 'string',
|
||||
description: 'Concrete blocking condition; required only with action blocked.',
|
||||
},
|
||||
},
|
||||
execute(args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
@@ -205,6 +212,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
if (args.action === 'edit') {
|
||||
requireDirectHuman(ctx, execution)
|
||||
if (args.blocked_reason !== undefined) {
|
||||
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
const goal = ctx.goals.edit(execution.agent, ref, replacements)
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{
|
||||
@@ -214,9 +224,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
if (args.action === 'pause' || args.action === 'resume') {
|
||||
requireDirectHuman(ctx, execution)
|
||||
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
|
||||
if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) {
|
||||
throw new HarnessError(
|
||||
'objective and max_goal_rounds are valid only with action edit',
|
||||
'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked',
|
||||
'GOAL_TOOL_INVALID_UPDATE',
|
||||
)
|
||||
}
|
||||
@@ -233,6 +243,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
'GOAL_TOOL_INVALID_UPDATE',
|
||||
)
|
||||
}
|
||||
if (args.action === 'complete' && args.blocked_reason !== undefined) {
|
||||
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
if (args.action === 'blocked'
|
||||
&& (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) {
|
||||
throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
if (args.action === 'blocked' && authority.kind === 'goal-round'
|
||||
&& authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) {
|
||||
throw new HarnessError(
|
||||
@@ -243,14 +260,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const goal = args.action === 'complete'
|
||||
? ctx.goals.complete(execution.agent, ref)
|
||||
: ctx.goals.block(execution.agent, ref)
|
||||
: ctx.goals.block(execution.agent, ref, {
|
||||
code: 'model-reported',
|
||||
message: args.blocked_reason as string,
|
||||
})
|
||||
observeMutation(terminalTurns, execution, authority.kind === 'goal-round')
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
},
|
||||
presentCall: args => present(
|
||||
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,
|
||||
'other',
|
||||
args.objective ?? args.goal_id,
|
||||
args.blocked_reason ?? args.objective ?? args.goal_id,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { decodeGoalChange } from '@deepseek-ai/dsh-goal'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
const PROCESS_TIMEOUT_MS = 30_000
|
||||
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
|
||||
const PAUSED_RESULT = '"phase":"paused"'
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
|
||||
child = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) return jsonlFiles(path)
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
||||
}))
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
async function runComposition(): Promise<{ stdout: string; stderr: string }> {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'goal-tools-e2e-'))
|
||||
const cwd = workdir
|
||||
return new Promise((resolve, reject) => {
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: [configPath],
|
||||
tsconfigPath: repoTsconfig,
|
||||
exposeInternals: true,
|
||||
env: {
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
})
|
||||
const proc = spawn(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...launch.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let pauseSent = false
|
||||
let inputClosed = false
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!pauseSent && stdout.includes('GOAL CREATED') && stdout.includes('\n> ')) {
|
||||
pauseSent = true
|
||||
proc.stdin.write('pause\n')
|
||||
}
|
||||
const pausedAt = stdout.indexOf(PAUSED_RESULT)
|
||||
if (!inputClosed && pausedAt >= 0 && stdout.indexOf('\n> ', pausedAt) >= 0) {
|
||||
inputClosed = true
|
||||
proc.stdin.end()
|
||||
}
|
||||
})
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL')
|
||||
reject(new Error(
|
||||
`goal-tools e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
))
|
||||
}, PROCESS_TIMEOUT_MS)
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) resolve({ stdout, stderr })
|
||||
else reject(new Error(`goal-tools e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
})
|
||||
proc.on('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
proc.stdin.write('start\n')
|
||||
})
|
||||
}
|
||||
|
||||
describe('goal tools through a real Loader, app, and stdio process', () => {
|
||||
it('creates, reads, and pauses one root goal with durable tool and state records', async () => {
|
||||
const { stdout, stderr } = await runComposition()
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stdout).toContain('goal-tools e2e ready.')
|
||||
expect(stdout).toContain('GOAL CREATED')
|
||||
expect(stdout).toContain(PAUSED_RESULT)
|
||||
expect(stdout).toContain('GOAL PAUSED')
|
||||
|
||||
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
const calls = events.filter(event => event.type === 'tool/call')
|
||||
expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal'])
|
||||
const results = events.filter(event => event.type === 'tool/result')
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results.every(event => !event.data.isError)).toBe(true)
|
||||
|
||||
const changes = events
|
||||
.filter(event => event.type === 'context/message' && event.data.source.kind === 'goal')
|
||||
.map(event => event.type === 'context/message' ? decodeGoalChange(event.data.meta) : undefined)
|
||||
expect(changes.map(change => change?.operation)).toEqual(['create', 'pause'])
|
||||
expect(changes[1]).toMatchObject({ goal: { phase: 'paused', revision: 2, maxGoalRounds: 7 } })
|
||||
expect(JSON.stringify(changes)).not.toContain('activation')
|
||||
|
||||
const headers = events.filter(event => event.type === 'request/header')
|
||||
expect(JSON.stringify(headers)).toContain('infer goal intent')
|
||||
expect(JSON.stringify(headers)).toContain('at least 3 consecutive goal rounds')
|
||||
}, TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -135,8 +135,8 @@ describe('goal tool registration and presentation', () => {
|
||||
card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship',
|
||||
})
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'blocked',
|
||||
})).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'goal-1' })
|
||||
goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.',
|
||||
})).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'resume',
|
||||
})).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' })
|
||||
@@ -391,6 +391,26 @@ describe('goal tool state transitions', () => {
|
||||
max_goal_rounds: 2,
|
||||
}, root.agent)
|
||||
expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const blockedWithoutReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'blocked',
|
||||
}, root.agent)
|
||||
expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const blockedWithEmptyReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ',
|
||||
}, root.agent)
|
||||
expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const completeWithReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.',
|
||||
}, root.agent)
|
||||
expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const editWithReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'edit',
|
||||
objective: 'still valid',
|
||||
blocked_reason: 'Not valid for edit.',
|
||||
}, root.agent)
|
||||
expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const malformedRef = await execute(ctx, 'update_goal', {
|
||||
goal_id: '', revision: 0, action: 'edit', objective: 'x',
|
||||
}, root.agent)
|
||||
@@ -423,16 +443,26 @@ describe('goal tool state transitions', () => {
|
||||
for (let round = 1; round <= 2; round += 1) {
|
||||
turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round })
|
||||
const result = await execute(ctx, 'update_goal', {
|
||||
goal_id: ref.id, revision: ref.revision, action: 'blocked',
|
||||
goal_id: ref.id,
|
||||
revision: ref.revision,
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The required credential is still unavailable.',
|
||||
}, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
|
||||
closeTurn(root, turn)
|
||||
}
|
||||
openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 })
|
||||
const blocked = await execute(ctx, 'update_goal', {
|
||||
goal_id: ref.id, revision: ref.revision, action: 'blocked',
|
||||
goal_id: ref.id,
|
||||
revision: ref.revision,
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The required credential is still unavailable.',
|
||||
}, root.agent)
|
||||
expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 3 })
|
||||
expect(resultGoal(blocked)).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' },
|
||||
roundsStarted: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('lets direct human authority block before the model threshold', async () => {
|
||||
@@ -440,8 +470,18 @@ describe('goal tool state transitions', () => {
|
||||
openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'human stop' })
|
||||
const blocked = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'blocked',
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The user asked to stop until a prerequisite is available.',
|
||||
}, root.agent)
|
||||
expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 0 })
|
||||
expect(resultGoal(blocked)).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: {
|
||||
code: 'model-reported',
|
||||
message: 'The user asked to stop until a prerequisite is available.',
|
||||
},
|
||||
roundsStarted: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,12 +41,15 @@ export type { AgentUnderTest } from './launcher.ts'
|
||||
* the client observes the selected update (`agent_message_chunk` by default),
|
||||
* then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the
|
||||
* step open for a terminal tool update that may follow the prompt response.
|
||||
* `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending
|
||||
* the prompt, then keeps the application live until that later update arrives.
|
||||
*/
|
||||
export type InputStep =
|
||||
| { op: 'initialize'; terminalOutput?: boolean }
|
||||
| { op: 'newSession' }
|
||||
| { op: 'newSessionExpectError'; additionalDirectories?: string[] }
|
||||
| { op: 'prompt'; text: string }
|
||||
| { op: 'promptAndWaitForAgentMessage'; text: string; waitForText: string }
|
||||
| { op: 'promptExpectError'; text: string }
|
||||
| {
|
||||
op: 'promptAndCancel'
|
||||
@@ -331,6 +334,15 @@ async function runStep(
|
||||
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
|
||||
return
|
||||
}
|
||||
case 'promptAndWaitForAgentMessage': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: promptAndWaitForAgentMessage before newSession')
|
||||
const updateDone = waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk'
|
||||
&& update.content.type === 'text' && update.content.text === step.waitForText)
|
||||
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
|
||||
await updateDone
|
||||
return
|
||||
}
|
||||
case 'promptExpectError': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: promptExpectError before newSession')
|
||||
|
||||
@@ -334,6 +334,21 @@ describe('runScenario', () => {
|
||||
expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled'))
|
||||
})
|
||||
|
||||
it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ prompt: 'respond' })
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [...boot, {
|
||||
op: 'promptAndWaitForAgentMessage',
|
||||
text: 'go',
|
||||
waitForText: 'thinking about it',
|
||||
}],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
expect(result.rawStdout).toContain('thinking about it')
|
||||
})
|
||||
|
||||
it('promptAndCancel can bracket cancellation with tool-call updates', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
|
||||
@@ -514,7 +514,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
|
||||
/** Project the effective registry view onto ACP discovery metadata. */
|
||||
const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent, 'acp').map(command => ({
|
||||
const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent).map(command => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
...command.input === undefined ? {} : { input: { hint: command.input.hint } },
|
||||
@@ -905,7 +905,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const controller = new AbortController()
|
||||
rec.commandAbort = controller
|
||||
try {
|
||||
const result = await commands.execute(rec.agent, 'acp', commandLine, controller.signal)
|
||||
const result = await commands.execute(rec.agent, commandLine, controller.signal)
|
||||
if (result !== undefined && result.text !== undefined && result.text !== '') {
|
||||
notify({
|
||||
sessionId: rec.agent.session.id,
|
||||
|
||||
@@ -55,7 +55,6 @@ describe('ACP plugin commands', () => {
|
||||
const dispose = harness.ctx.commands.register({
|
||||
name: 'alpha',
|
||||
description: 'Alpha command',
|
||||
surfaces: ['acp'],
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
@@ -126,7 +125,7 @@ describe('ACP plugin commands', () => {
|
||||
})
|
||||
|
||||
expect(response.stopReason).toBe('end_turn')
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ surface: 'acp', rawInput: ' raw args ' }))
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' }))
|
||||
expect(messageText(harness, sessionId)).toContain('DIRECT RESULT')
|
||||
const updatesAfterText = harness.sessionUpdates.length
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] })
|
||||
@@ -260,7 +259,7 @@ describe('ACP plugin commands', () => {
|
||||
if (agentA === undefined) throw new Error('session A has no agent')
|
||||
await agentA.ctx.inject(['commands'], (commandCtx) => {
|
||||
commandCtx.commands.register({
|
||||
name: 'private', description: 'Only session A', surfaces: ['acp'],
|
||||
name: 'private', description: 'Only session A',
|
||||
handler: () => ({ kind: 'success', text: 'A ONLY' }),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,9 +4,9 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu
|
||||
|
||||
## Service contract
|
||||
|
||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
||||
|
||||
`list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface.
|
||||
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
|
||||
|
||||
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
|
||||
|
||||
|
||||
@@ -11,11 +11,6 @@ import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
export const name = 'commands'
|
||||
|
||||
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
|
||||
const SURFACE_NAME = /^[a-z][a-z0-9-]*$/u
|
||||
const DEFAULT_SURFACES = ['tui', 'acp'] as const
|
||||
|
||||
/** A UI adapter capable of listing and executing human commands. */
|
||||
export type CommandSurface = 'tui' | 'acp' | (string & {})
|
||||
|
||||
/** Immutable command input metadata compatible with ACP unstructured input. */
|
||||
export interface CommandInputDescriptor {
|
||||
@@ -27,8 +22,6 @@ export interface CommandInputDescriptor {
|
||||
export interface CommandInvocation {
|
||||
/** Exact agent whose human-facing surface received the command. */
|
||||
readonly agent: Agent
|
||||
/** UI adapter that dispatched the command. */
|
||||
readonly surface: CommandSurface
|
||||
/** Exact text following the registered command name, including separator whitespace. */
|
||||
readonly rawInput: string
|
||||
/** Cancellation signal owned by the dispatching UI request. */
|
||||
@@ -48,8 +41,6 @@ export interface CommandDefinition {
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
/** Surfaces exposing this command; omission means both shipped surfaces. */
|
||||
readonly surfaces?: readonly CommandSurface[]
|
||||
/** Execute against the receiving agent without sending the command to the model. */
|
||||
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
|
||||
}
|
||||
@@ -62,8 +53,6 @@ export interface CommandDescriptor {
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
/** Surfaces on which this definition is visible. */
|
||||
readonly surfaces: readonly CommandSurface[]
|
||||
}
|
||||
|
||||
/** Syntactically valid slash command before registry resolution. */
|
||||
@@ -75,7 +64,7 @@ export interface ParsedCommand {
|
||||
}
|
||||
|
||||
interface RegisteredCommand {
|
||||
readonly definition: CommandDefinition & { readonly surfaces: readonly CommandSurface[] }
|
||||
readonly definition: CommandDefinition
|
||||
readonly descriptor: CommandDescriptor
|
||||
}
|
||||
|
||||
@@ -175,33 +164,16 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
|
||||
}
|
||||
input = Object.freeze({ hint: rawInput.hint })
|
||||
}
|
||||
const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)]
|
||||
if (surfaces.length === 0) {
|
||||
throw new TypeError(`command "${definition.name}" must expose at least one surface`)
|
||||
}
|
||||
const unique = new Set<CommandSurface>()
|
||||
for (const surface of surfaces) {
|
||||
if (!SURFACE_NAME.test(surface)) {
|
||||
throw new TypeError(`command "${definition.name}" surface "${surface}" must match ${String(SURFACE_NAME)}`)
|
||||
}
|
||||
if (unique.has(surface)) {
|
||||
throw new TypeError(`command "${definition.name}" surface "${surface}" is duplicated`)
|
||||
}
|
||||
unique.add(surface)
|
||||
}
|
||||
const frozenSurfaces = Object.freeze(surfaces)
|
||||
const normalized = Object.freeze({
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
...input === undefined ? {} : { input },
|
||||
surfaces: frozenSurfaces,
|
||||
handler: definition.handler,
|
||||
})
|
||||
const descriptor = Object.freeze({
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
...normalized.input === undefined ? {} : { input: normalized.input },
|
||||
surfaces: normalized.surfaces,
|
||||
})
|
||||
return { definition: normalized, descriptor }
|
||||
}
|
||||
@@ -242,7 +214,7 @@ export class CommandService extends Service {
|
||||
|
||||
/**
|
||||
* Register a global or calling-agent-scoped command.
|
||||
* @param definition - discovery metadata, surface mask, and direct UI handler.
|
||||
* @param definition - discovery metadata and direct UI handler.
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void {
|
||||
@@ -268,14 +240,12 @@ export class CommandService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* List the effective immutable command descriptors for one agent and surface.
|
||||
* List the effective immutable command descriptors for one agent.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param surface - UI adapter requesting discovery metadata.
|
||||
* @returns name-sorted descriptors after scoped shadowing and surface filtering.
|
||||
* @returns name-sorted descriptors after scoped shadowing.
|
||||
*/
|
||||
list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] {
|
||||
list(agent: Agent): readonly CommandDescriptor[] {
|
||||
return Object.freeze([...this.view(agent).values()]
|
||||
.filter(command => command.definition.surfaces.includes(surface))
|
||||
.map(command => command.descriptor)
|
||||
// Names are unique in the effective view, so equality is impossible.
|
||||
.sort((left, right) => left.name < right.name ? -1 : 1))
|
||||
@@ -284,35 +254,31 @@ export class CommandService extends Service {
|
||||
/**
|
||||
* Resolve one effective command definition.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param surface - UI adapter performing the lookup.
|
||||
* @param name - command name without a slash.
|
||||
* @returns the scoped shadow or global definition when visible on the surface.
|
||||
* @returns the scoped shadow or global definition.
|
||||
*/
|
||||
find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined {
|
||||
const command = this.view(agent).get(name)
|
||||
return command?.definition.surfaces.includes(surface) === true ? command.definition : undefined
|
||||
find(agent: Agent, name: string): CommandDefinition | undefined {
|
||||
return this.view(agent).get(name)?.definition
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute a known command without sending it to the model.
|
||||
* @param agent - exact receiving agent.
|
||||
* @param surface - dispatching UI adapter.
|
||||
* @param line - complete slash-command line.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns a detached result, or `undefined` when syntax/name/surface does not resolve.
|
||||
* @returns a detached result, or `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
async execute(
|
||||
agent: Agent,
|
||||
surface: CommandSurface,
|
||||
line: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandResult | undefined> {
|
||||
const parsed = parseCommand(line)
|
||||
if (parsed === undefined) return undefined
|
||||
const command = this.view(agent).get(parsed.name)
|
||||
if (command === undefined || !command.definition.surfaces.includes(surface)) return undefined
|
||||
if (command === undefined) return undefined
|
||||
if (signal.aborted) throw abortError(signal)
|
||||
const invocation = Object.freeze({ agent, surface, rawInput: parsed.rawInput, signal })
|
||||
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
|
||||
const output = command.definition.handler(invocation)
|
||||
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('parseCommand()', () => {
|
||||
})
|
||||
|
||||
describe('CommandService', () => {
|
||||
it('lists immutable global descriptors with default surfaces and ACP input metadata', async () => {
|
||||
it('lists immutable global descriptors with input metadata', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const definition: CommandDefinition = {
|
||||
@@ -55,20 +55,17 @@ describe('CommandService', () => {
|
||||
}
|
||||
ctx.commands.register(definition)
|
||||
|
||||
const listed = ctx.commands.list(agent, 'acp')
|
||||
const listed = ctx.commands.list(agent)
|
||||
expect(listed).toEqual([{
|
||||
name: 'inspect',
|
||||
description: 'Inspect state',
|
||||
input: { hint: '<target>' },
|
||||
surfaces: ['tui', 'acp'],
|
||||
}])
|
||||
expect(Object.isFrozen(listed)).toBe(true)
|
||||
expect(Object.isFrozen(listed[0])).toBe(true)
|
||||
expect(Object.isFrozen(listed[0]?.input)).toBe(true)
|
||||
expect(Object.isFrozen(listed[0]?.surfaces)).toBe(true)
|
||||
expect(ctx.commands.find(agent, 'tui', 'inspect')).toMatchObject({ name: 'inspect' })
|
||||
expect(ctx.commands.find(agent, 'other', 'inspect')).toBeUndefined()
|
||||
expect(ctx.commands.find(agent, 'tui', 'missing')).toBeUndefined()
|
||||
expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
|
||||
expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sorts distinct effective command names', async () => {
|
||||
@@ -77,7 +74,7 @@ describe('CommandService', () => {
|
||||
ctx.commands.register(command('zeta'))
|
||||
ctx.commands.register(command('alpha'))
|
||||
ctx.commands.register(command('middle'))
|
||||
expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
|
||||
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
|
||||
})
|
||||
|
||||
it('uses agent-scoped shadows and removes them with their scope', async () => {
|
||||
@@ -85,17 +82,16 @@ describe('CommandService', () => {
|
||||
const { scope, agent } = await mintAgentScope(ctx, 'a')
|
||||
const other = { id: 'other' as SessionId } as Agent
|
||||
ctx.commands.register(command('shared', 'global'))
|
||||
scope.ctx.commands.register({ ...command('shared', 'scoped'), surfaces: ['tui'] })
|
||||
scope.ctx.commands.register(command('shared', 'scoped'))
|
||||
|
||||
expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['shared'])
|
||||
expect(ctx.commands.list(agent, 'acp')).toEqual([])
|
||||
expect(ctx.commands.find(agent, 'tui', 'shared')?.handler).toBeDefined()
|
||||
expect(ctx.commands.list(other, 'acp').map(item => item.name)).toEqual(['shared'])
|
||||
expect(await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))
|
||||
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
|
||||
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
|
||||
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
|
||||
expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
|
||||
.toEqual({ kind: 'success', text: 'scoped' })
|
||||
|
||||
await scope.dispose()
|
||||
expect((await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
})
|
||||
|
||||
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
|
||||
@@ -124,14 +120,14 @@ describe('CommandService', () => {
|
||||
ctx.on('commands/change', afterFailures)
|
||||
const removeContained = ctx.commands.register(command('contained'))
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
expect(ctx.commands.find(agent, 'tui', 'contained')).toBeDefined()
|
||||
expect(ctx.commands.find(agent, 'contained')).toBeDefined()
|
||||
expect(afterFailures).toHaveBeenCalledTimes(1)
|
||||
await vi.waitFor(() => {
|
||||
expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
|
||||
expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
|
||||
})
|
||||
removeContained()
|
||||
expect(ctx.commands.find(agent, 'tui', 'contained')).toBeUndefined()
|
||||
expect(ctx.commands.find(agent, 'contained')).toBeUndefined()
|
||||
expect(afterFailures).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -155,22 +151,20 @@ describe('CommandService', () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' }))
|
||||
ctx.commands.register({ name: 'run', description: 'Run it', surfaces: ['acp'], handler: seen })
|
||||
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = await ctx.commands.execute(agent, 'acp', '/run untouched ', controller.signal)
|
||||
const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
|
||||
expect(result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent,
|
||||
surface: 'acp',
|
||||
rawInput: ' untouched ',
|
||||
signal: controller.signal,
|
||||
}))
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/run', controller.signal)).resolves.toBeUndefined()
|
||||
await expect(ctx.commands.execute(agent, 'acp', 'run', controller.signal)).resolves.toBeUndefined()
|
||||
await expect(ctx.commands.execute(agent, 'acp', '/missing', controller.signal)).resolves.toBeUndefined()
|
||||
await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined()
|
||||
await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops awaiting an aborted handler and handles an already-aborted signal', async () => {
|
||||
@@ -183,18 +177,18 @@ describe('CommandService', () => {
|
||||
handler: () => new Promise((resolve) => { release = resolve }),
|
||||
})
|
||||
const running = new AbortController()
|
||||
const promise = ctx.commands.execute(agent, 'tui', '/wait', running.signal)
|
||||
const promise = ctx.commands.execute(agent, '/wait', running.signal)
|
||||
running.abort('operator cancelled command')
|
||||
await expect(promise).rejects.toThrow('operator cancelled command')
|
||||
release({ kind: 'success', text: 'late' })
|
||||
|
||||
const already = new AbortController()
|
||||
already.abort(new Error('already gone'))
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/wait', already.signal)).rejects.toThrow('already gone')
|
||||
await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone')
|
||||
|
||||
const defaultReason = new AbortController()
|
||||
defaultReason.abort({ source: 'test' })
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
|
||||
await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
|
||||
})
|
||||
|
||||
it('propagates an asynchronously rejected handler', async () => {
|
||||
@@ -205,7 +199,7 @@ describe('CommandService', () => {
|
||||
description: 'Reject',
|
||||
handler: () => Promise.reject(new Error('handler rejected')),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/reject', new AbortController().signal))
|
||||
await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal))
|
||||
.rejects.toThrow('handler rejected')
|
||||
|
||||
ctx.commands.register({
|
||||
@@ -214,7 +208,7 @@ describe('CommandService', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization
|
||||
handler: () => Promise.reject('not an Error'),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal))
|
||||
await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal))
|
||||
.rejects.toThrow('command handler rejected with a non-Error value: not an Error')
|
||||
|
||||
const hostile = { toString(): string { throw new Error('cannot render') } }
|
||||
@@ -224,7 +218,7 @@ describe('CommandService', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization
|
||||
handler: () => Promise.reject(hostile),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/reject-hostile', new AbortController().signal))
|
||||
await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal))
|
||||
.rejects.toMatchObject({
|
||||
message: 'command handler rejected with a non-Error value: <unrenderable thrown value>',
|
||||
cause: hostile,
|
||||
@@ -243,7 +237,7 @@ describe('CommandService', () => {
|
||||
return { kind: 'success' }
|
||||
},
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/self-abort', controller.signal))
|
||||
await expect(ctx.commands.execute(agent, '/self-abort', controller.signal))
|
||||
.rejects.toThrow('aborted in handler')
|
||||
})
|
||||
|
||||
@@ -255,7 +249,7 @@ describe('CommandService', () => {
|
||||
description: 'Denied',
|
||||
handler: () => ({ kind: 'error', text: 'not now' }),
|
||||
})
|
||||
const result = await ctx.commands.execute(agent, 'tui', '/denied', new AbortController().signal)
|
||||
const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
|
||||
@@ -264,7 +258,7 @@ describe('CommandService', () => {
|
||||
description: 'No output',
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
const silent = await ctx.commands.execute(agent, 'tui', '/silent', new AbortController().signal)
|
||||
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
|
||||
expect(silent).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent)).toBe(true)
|
||||
})
|
||||
@@ -273,9 +267,6 @@ describe('CommandService', () => {
|
||||
[{ ...command('Bad') }, /command name/],
|
||||
[{ ...command('empty-description'), description: ' ' }, /description/],
|
||||
[{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
|
||||
[{ ...command('no-surface'), surfaces: [] }, /at least one surface/],
|
||||
[{ ...command('bad-surface'), surfaces: ['ACP'] }, /surface/],
|
||||
[{ ...command('duplicate-surface'), surfaces: ['tui', 'tui'] }, /duplicated/],
|
||||
[{ ...command('bad-handler'), handler: undefined }, /handler/],
|
||||
] as const)('rejects invalid definition %#', async (definition, expected) => {
|
||||
const ctx = await mount()
|
||||
@@ -298,6 +289,6 @@ describe('CommandService', () => {
|
||||
description: 'Broken',
|
||||
handler: () => output as never,
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, 'tui', '/broken', new AbortController().signal)).rejects.toThrow(expected)
|
||||
await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1144,7 +1144,7 @@ export function createTuiChat(
|
||||
}
|
||||
|
||||
const showHelp = (): void => {
|
||||
const commandLines = ctx.commands.list(agent, 'tui').map((command) => {
|
||||
const commandLines = ctx.commands.list(agent).map((command) => {
|
||||
const input = command.input === undefined ? '' : ` ${command.input.hint}`
|
||||
return `/${command.name}${input} — ${command.description}`
|
||||
})
|
||||
@@ -1162,7 +1162,7 @@ export function createTuiChat(
|
||||
|
||||
const refreshCommandAutocomplete = (): void => {
|
||||
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
|
||||
ctx.commands.list(agent, 'tui').map(command => ({
|
||||
ctx.commands.list(agent).map(command => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
})),
|
||||
@@ -1179,19 +1179,16 @@ export function createTuiChat(
|
||||
commandCtx.commands.register({
|
||||
name: 'help',
|
||||
description: 'Show keyboard shortcuts and commands',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { showHelp(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'clear',
|
||||
description: 'Clear the transcript view (session history is unchanged)',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'cancel',
|
||||
description: 'Cancel the active turn',
|
||||
surfaces: ['tui'],
|
||||
handler: () => {
|
||||
if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' }
|
||||
agent.cancel('cancelled from terminal')
|
||||
@@ -1201,25 +1198,21 @@ export function createTuiChat(
|
||||
commandCtx.commands.register({
|
||||
name: 'reasoning',
|
||||
description: 'Toggle reasoning blocks',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { toggleReasoning(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'tools',
|
||||
description: 'Expand or collapse all tool cards',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { toggleTools(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'redraw',
|
||||
description: 'Invalidate components and redraw the terminal',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'exit',
|
||||
description: 'Exit after the active turn reaches idle',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { requestExit(); return { kind: 'success' } },
|
||||
})
|
||||
})
|
||||
@@ -1227,7 +1220,7 @@ export function createTuiChat(
|
||||
const runCommand = (text: string): void => {
|
||||
const controller = new AbortController()
|
||||
commandControllers.add(controller)
|
||||
void ctx.commands.execute(agent, 'tui', text, controller.signal).then(
|
||||
void ctx.commands.execute(agent, text, controller.signal).then(
|
||||
(result) => {
|
||||
if (disposed) return
|
||||
if (result === undefined) {
|
||||
|
||||
@@ -442,13 +442,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
name: 'plugin-check',
|
||||
description: 'Run a plugin command',
|
||||
input: { hint: '<value>' },
|
||||
surfaces: ['tui'],
|
||||
handler,
|
||||
})
|
||||
result.ctx.commands.register({
|
||||
name: 'plugin-fail',
|
||||
description: 'Fail a plugin command',
|
||||
surfaces: ['tui'],
|
||||
handler: () => { throw new Error('plugin command exploded') },
|
||||
})
|
||||
|
||||
@@ -459,7 +457,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
const invocation = handler.mock.calls[0]?.[0]
|
||||
expect(invocation?.agent).toBe(result.agent)
|
||||
expect(invocation?.surface).toBe('tui')
|
||||
// pi-tui's Editor owns terminal-line normalization and removes trailing
|
||||
// spaces before onSubmit; the registry preserves the adapter-delivered line.
|
||||
expect(invocation?.rawInput).toBe(' value')
|
||||
@@ -472,10 +469,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('/plugin-check <value> — Run a plugin command')
|
||||
expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toContain('help')
|
||||
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help')
|
||||
|
||||
await result.controller.dispose()
|
||||
expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toEqual([
|
||||
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
|
||||
'plugin-check',
|
||||
'plugin-fail',
|
||||
])
|
||||
@@ -490,7 +487,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.ctx.commands.register({
|
||||
name: 'wait-plugin',
|
||||
description: 'Wait until disposal',
|
||||
surfaces: ['tui'],
|
||||
handler: ({ signal }) => {
|
||||
commandSignal = signal
|
||||
started()
|
||||
@@ -518,7 +514,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.ctx.commands.register({
|
||||
name: 'late-success',
|
||||
description: 'Resolve while the TUI closes',
|
||||
surfaces: ['tui'],
|
||||
handler: () => new Promise((resolve) => {
|
||||
resolveCommand = resolve
|
||||
started()
|
||||
@@ -1027,7 +1022,7 @@ describe('terminal mounting', () => {
|
||||
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() }))
|
||||
.toThrow('terminal startup failed')
|
||||
await tick()
|
||||
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!, 'tui')).toEqual([])
|
||||
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
|
||||
expect(terminal.stopped).toBe(1)
|
||||
expect(terminal.progress).toEqual([false, true, false])
|
||||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
||||
|
||||
Reference in New Issue
Block a user