feat(goal): add human goal command
This commit is contained in:
@@ -7,5 +7,6 @@ The goal family owns durable objective state independently of the model-facing t
|
||||
| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` |
|
||||
| `goal-session/` | Same-session goal-round admission, outcome mapping, and lifecycle race fencing | — |
|
||||
| `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — |
|
||||
| `command-goal/` | Human-facing `/goal` status and lifecycle control over the command plane | — |
|
||||
|
||||
Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams.
|
||||
|
||||
56
packages/goal/command-goal/README.md
Normal file
56
packages/goal/command-goal/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @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 RFC](../../../docs/rfc/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 <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. |
|
||||
| `/goal resume` | Resume a stopped goal or rearm an active goal after session resume/fork, subject to its remaining round cap. |
|
||||
| `/goal clear` | Clear the current pointer while retaining its durable history and tombstone. |
|
||||
|
||||
Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear.
|
||||
|
||||
Expected domain rejections become direct command errors. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin.
|
||||
|
||||
## Composition
|
||||
|
||||
The producer injects `commands` and `goals`. A custom app mounts their owners plus this plugin; automatic continuation remains an independent choice:
|
||||
|
||||
```yaml
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
- id: goal
|
||||
name: '@deepseek-ai/dsh-goal'
|
||||
- id: command-goal
|
||||
name: '@deepseek-ai/dsh-command-goal'
|
||||
```
|
||||
|
||||
The terminal and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Human `/goal` control
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The slash input and direct status/error output are absent from model requests. An accepted mutation later appears through the goal domain's raw `<goal_state>` snapshot or clear tombstone; this preserves the model-visible-is-logged invariant without logging presentation text.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Reading status or receiving a direct command error adds no model tokens. Each accepted mutation adds the goal domain's retained full snapshot, and an enabled same-session driver may add later goal-round prompts.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Command discovery and direct output do not affect the cache. A mutation appends after the reusable history prefix; later compaction may replace the derived-history suffix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic on both TUI and ACP.
|
||||
- **No per-command round-cap argument** — `defaultMaxGoalRounds` remains deployment config, while a direct human request may ask the model to edit `max_goal_rounds` through the separately authorized goal tool.
|
||||
- **No continuous status widget** — bare `/goal` is the portable observation surface; adapter-specific badges and reconnectable command output remain future UI work.
|
||||
- **TUI and ACP only** — the line-oriented stdio and JSON-RPC adapters do not consume `ctx.commands`. Their ordinary human prompts can still authorize the model-facing goal tools when those are composed.
|
||||
38
packages/goal/command-goal/package.json
Normal file
38
packages/goal/command-goal/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-command-goal",
|
||||
"description": "Human-facing slash command for persisted same-session goals",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
165
packages/goal/command-goal/src/index.ts
Normal file
165
packages/goal/command-goal/src/index.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Human-facing `/goal` command over the persisted same-session goal domain.
|
||||
* @module @deepseek-ai/dsh-command-goal
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
import { GoalError } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
|
||||
|
||||
export const name = 'command-goal'
|
||||
export const inject = ['commands', 'goals']
|
||||
|
||||
const USAGE = 'Usage: /goal [<objective>|clear|edit <objective>|pause|resume]'
|
||||
|
||||
type GoalCommand =
|
||||
| { readonly kind: 'show' }
|
||||
| { readonly kind: 'create'; readonly objective: string }
|
||||
| { readonly kind: 'edit'; readonly objective: string }
|
||||
| { readonly kind: 'invalid-edit' }
|
||||
| { readonly kind: 'pause' }
|
||||
| { readonly kind: 'resume' }
|
||||
| { readonly kind: 'clear' }
|
||||
|
||||
/** Fail loudly if a locally closed union gains an unhandled member. */
|
||||
/* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */
|
||||
function assertNever(value: never, label: string): never {
|
||||
throw new TypeError(`unknown ${label}: ${String(value)}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/** Parse only the grammar owned by `/goal`; arbitrary other input is an objective. */
|
||||
function parseGoalCommand(rawInput: string): GoalCommand {
|
||||
const input = rawInput.trim()
|
||||
if (input.length === 0) return { kind: 'show' }
|
||||
const control = input.toLowerCase()
|
||||
if (control === 'clear') return { kind: 'clear' }
|
||||
if (control === 'pause') return { kind: 'pause' }
|
||||
if (control === 'resume') return { kind: 'resume' }
|
||||
if (control === 'edit') return { kind: 'invalid-edit' }
|
||||
if (/^edit(?=\s)/iu.test(input)) return { kind: 'edit', objective: input.slice(4).trim() }
|
||||
return { kind: 'create', objective: input }
|
||||
}
|
||||
|
||||
/** Human label for one durable goal phase. */
|
||||
function phaseLabel(phase: GoalPhase): string {
|
||||
switch (phase) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/** Commands that are meaningful from one exact live state. */
|
||||
function commandHint(goal: GoalView): string {
|
||||
if (goal.phase === 'active') {
|
||||
return goal.activation === 'armed'
|
||||
? '/goal edit <objective>, /goal pause, /goal clear'
|
||||
: '/goal edit <objective>, /goal resume, /goal clear'
|
||||
}
|
||||
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'
|
||||
case 'complete':
|
||||
return '/goal <objective>, /goal clear'
|
||||
/* v8 ignore next 2 -- the active branch and every non-active phase are handled above */
|
||||
default: return assertNever(goal.phase, 'goal phase')
|
||||
}
|
||||
}
|
||||
|
||||
/** Render direct UI output without exposing compare-and-set internals. */
|
||||
function renderGoal(title: string, goal: GoalView): CommandResult {
|
||||
return {
|
||||
kind: 'success',
|
||||
text: [
|
||||
title,
|
||||
`Status: ${phaseLabel(goal.phase)}`,
|
||||
`Objective: ${goal.objective}`,
|
||||
`Rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`,
|
||||
`Activation: ${goal.activation}`,
|
||||
'',
|
||||
`Commands: ${commandHint(goal)}`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact current compare-and-set ref. */
|
||||
function goalRef(goal: GoalView): GoalRef {
|
||||
return { id: goal.id, revision: goal.revision }
|
||||
}
|
||||
|
||||
/** Direct error for an operation that requires a current goal. */
|
||||
function missingGoal(action: string): CommandResult {
|
||||
return {
|
||||
kind: 'error',
|
||||
text: `No goal is currently set; /goal ${action} requires one. ${USAGE}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute one parsed human command through the domain that owns persistence. */
|
||||
function executeGoalCommand(ctx: Context, invocation: CommandInvocation): CommandResult {
|
||||
const command = parseGoalCommand(invocation.rawInput)
|
||||
try {
|
||||
const current = ctx.goals.get(invocation.agent)
|
||||
switch (command.kind) {
|
||||
case 'show':
|
||||
return current === undefined
|
||||
? { kind: 'success', text: `No goal is currently set.\n${USAGE}` }
|
||||
: renderGoal('Goal', current)
|
||||
case 'invalid-edit':
|
||||
return { kind: 'error', text: `Goal editing requires a replacement objective.\n${USAGE}` }
|
||||
case 'create':
|
||||
if (current !== undefined && current.phase !== 'complete') {
|
||||
return {
|
||||
kind: 'error',
|
||||
text: `A goal is already ${phaseLabel(current.phase)}. Use /goal edit <objective> to change it or /goal clear before replacing it.`,
|
||||
}
|
||||
}
|
||||
return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective }))
|
||||
case 'edit':
|
||||
if (current === undefined) return missingGoal('edit')
|
||||
if (current.phase === 'complete') {
|
||||
return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective }))
|
||||
}
|
||||
return renderGoal(
|
||||
'Goal updated',
|
||||
ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }),
|
||||
)
|
||||
case 'pause':
|
||||
if (current === undefined) return missingGoal('pause')
|
||||
return renderGoal('Goal paused', ctx.goals.pause(invocation.agent, goalRef(current)))
|
||||
case 'resume':
|
||||
if (current === undefined) return missingGoal('resume')
|
||||
return renderGoal('Goal resumed', ctx.goals.resume(invocation.agent, goalRef(current)))
|
||||
case 'clear':
|
||||
if (current === undefined) return { kind: 'success', text: 'No goal to clear.' }
|
||||
ctx.goals.clear(invocation.agent, goalRef(current))
|
||||
return { kind: 'success', text: 'Goal cleared.' }
|
||||
/* v8 ignore next 2 -- GoalCommand is closed and every member is handled above */
|
||||
default: return assertNever(command, 'goal command')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof GoalError) return { kind: 'error', text: error.message }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the Codex-shaped `/goal` human command on TUI and ACP surfaces. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.commands.register({
|
||||
name: 'goal',
|
||||
description: 'set or view the goal for a long-running task',
|
||||
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
|
||||
handler: invocation => executeGoalCommand(ctx, invocation),
|
||||
})
|
||||
}
|
||||
254
packages/goal/command-goal/tests/command-goal.spec.ts
Normal file
254
packages/goal/command-goal/tests/command-goal.spec.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
|
||||
|
||||
interface Harness {
|
||||
readonly ctx: Context
|
||||
readonly agent: Agent
|
||||
readonly session: Session
|
||||
readonly plugin: Awaited<ReturnType<Context['plugin']>>
|
||||
}
|
||||
|
||||
/** Number the next balanced injection or message turn. */
|
||||
function nextTurn(session: Session): number {
|
||||
return session.events.reduce(
|
||||
(maximum, event) => event.type === 'turn/start' ? Math.max(maximum, event.data.turn) : maximum,
|
||||
0,
|
||||
) + 1
|
||||
}
|
||||
|
||||
/** Append one idle injection using the public Agent contract's balanced shape. */
|
||||
function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
|
||||
const source: MessageSource = options?.source ?? { kind: 'user' }
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.envelope === undefined ? {} : { envelope: options.envelope },
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
/** Build a live idle agent accepted by the exact-identity goal service. */
|
||||
function stubAgent(id: string): { agent: Agent; session: Session } {
|
||||
const session = new Session(SessionId(id))
|
||||
let status: AgentStatus = 'idle'
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
send() {},
|
||||
steer() {},
|
||||
inject(content, options) { appendInjection(session, content, options) },
|
||||
cancel() { status = 'idle' },
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
return { agent, session }
|
||||
}
|
||||
|
||||
/** Mount the real command registry, goal domain, and producer. */
|
||||
async function harness(): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
const plugin = await ctx.plugin(commandGoal)
|
||||
const { agent, session } = stubAgent(`command-goal-${Math.random()}`)
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, agent, session, plugin }
|
||||
}
|
||||
|
||||
/** Execute `/goal` through the same registry boundary as a UI adapter. */
|
||||
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,
|
||||
)
|
||||
if (result === undefined) throw new Error('goal command was not registered')
|
||||
return result
|
||||
}
|
||||
|
||||
/** Current exact compare-and-set ref. */
|
||||
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 () => {
|
||||
const test = await harness()
|
||||
expect(commandGoal.name).toBe('command-goal')
|
||||
expect(commandGoal.inject).toEqual(['commands', 'goals'])
|
||||
expect('default' in commandGoal).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(commandGoal)).toBe(commandGoal)
|
||||
|
||||
expect(test.ctx.commands.list(test.agent, 'tui')).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()
|
||||
|
||||
await test.plugin.dispose()
|
||||
expect(test.ctx.commands.find(test.agent, 'tui', 'goal')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('/goal human command', () => {
|
||||
it('shows an empty status without mutating the session', async () => {
|
||||
const test = await harness()
|
||||
await expect(run(test)).resolves.toEqual({
|
||||
kind: 'success',
|
||||
text: 'No goal is currently set.\nUsage: /goal [<objective>|clear|edit <objective>|pause|resume]',
|
||||
})
|
||||
expect(test.session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => {
|
||||
const test = await harness()
|
||||
const created = await run(test, '\n finish the release ')
|
||||
expect(created.kind).toBe('success')
|
||||
expect(created.text).toContain('Goal created\nStatus: active')
|
||||
expect(created.text).toContain('Objective: finish the release')
|
||||
expect(created.text).toContain('Rounds: 0/256')
|
||||
expect(created.text).toContain('Activation: armed')
|
||||
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
|
||||
expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
|
||||
|
||||
const count = test.session.events.length
|
||||
await expect(run(test, ' replacement')).resolves.toEqual({
|
||||
kind: 'error',
|
||||
text: 'A goal is already active. Use /goal edit <objective> to change it or /goal clear before replacing it.',
|
||||
})
|
||||
expect(test.session.events).toHaveLength(count)
|
||||
})
|
||||
|
||||
it('treats only exact control words as controls', async () => {
|
||||
const test = await harness()
|
||||
await run(test, ' pause everything only after verification')
|
||||
expect(test.ctx.goals.get(test.agent)?.objective).toBe('pause everything only after verification')
|
||||
})
|
||||
|
||||
it('edits inline, requires an objective, and starts a new goal when the old one is complete', async () => {
|
||||
const empty = await harness()
|
||||
const invalidEdit = await run(empty, ' edit')
|
||||
expect(invalidEdit.kind).toBe('error')
|
||||
expect(invalidEdit.text).toContain('requires a replacement objective')
|
||||
const missingEdit = await run(empty, ' edit replacement')
|
||||
expect(missingEdit.kind).toBe('error')
|
||||
expect(missingEdit.text).toContain('/goal edit requires one')
|
||||
|
||||
const test = await harness()
|
||||
await run(test, ' first')
|
||||
const first = test.ctx.goals.get(test.agent)!
|
||||
const updated = await run(test, ' EDIT\n second ')
|
||||
expect(updated.kind).toBe('success')
|
||||
expect(updated.text).toContain('Goal updated')
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ id: first.id, objective: 'second', revision: 2 })
|
||||
|
||||
const current = test.ctx.goals.get(test.agent)!
|
||||
test.ctx.goals.complete(test.agent, ref(current))
|
||||
const replacement = await run(test, ' edit third')
|
||||
expect(replacement.kind).toBe('success')
|
||||
expect(replacement.text).toContain('Goal created')
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ objective: 'third', revision: 1 })
|
||||
expect(test.ctx.goals.get(test.agent)?.id).not.toBe(first.id)
|
||||
})
|
||||
|
||||
it('returns direct missing-state results for pause, resume, and clear', async () => {
|
||||
const test = await harness()
|
||||
const missingPause = await run(test, ' pause')
|
||||
expect(missingPause.kind).toBe('error')
|
||||
expect(missingPause.text).toContain('/goal pause requires one')
|
||||
const missingResume = await run(test, ' resume')
|
||||
expect(missingResume.kind).toBe('error')
|
||||
expect(missingResume.text).toContain('/goal resume requires one')
|
||||
await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'No goal to clear.' })
|
||||
})
|
||||
|
||||
it('pauses, resumes, clears, and converts expected domain rejections to command errors', async () => {
|
||||
const test = await harness()
|
||||
await run(test, ' work')
|
||||
const redundantResume = await run(test, ' RESUME')
|
||||
expect(redundantResume.kind).toBe('error')
|
||||
expect(redundantResume.text).toContain('already active and armed')
|
||||
const paused = await run(test, ' PAUSE')
|
||||
expect(paused.kind).toBe('success')
|
||||
expect(paused.text).toContain('Goal paused')
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused', activation: 'disarmed' })
|
||||
const resumed = await run(test, ' resume')
|
||||
expect(resumed.kind).toBe('success')
|
||||
expect(resumed.text).toContain('Goal resumed')
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', activation: 'armed' })
|
||||
await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'Goal cleared.' })
|
||||
expect(test.ctx.goals.get(test.agent)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows every durable phase and distinguishes disarmed active state', async () => {
|
||||
const test = await harness()
|
||||
test.ctx.goals.create(test.agent, { objective: 'state matrix', maxGoalRounds: 1 })
|
||||
test.ctx.goals.disarm(test.agent)
|
||||
expect((await run(test)).text)
|
||||
.toContain('Status: active\nObjective: state matrix\nRounds: 0/1\nActivation: disarmed')
|
||||
expect((await run(test)).text).toContain('/goal resume')
|
||||
|
||||
let goal = test.ctx.goals.get(test.agent)!
|
||||
goal = test.ctx.goals.resume(test.agent, ref(goal))
|
||||
goal = test.ctx.goals.pause(test.agent, ref(goal))
|
||||
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.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).not.toContain('/goal resume')
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('does not turn unexpected implementation failures into expected command results', async () => {
|
||||
const test = await harness()
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('unexpected failure') })
|
||||
await expect(run(test)).rejects.toThrow('unexpected failure')
|
||||
})
|
||||
})
|
||||
24
packages/goal/command-goal/tsconfig.json
Normal file
24
packages/goal/command-goal/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../goal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -69,5 +69,5 @@ Schemas are prefix-stable while their definitions and visibility are unchanged.
|
||||
|
||||
- **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal.
|
||||
- **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred.
|
||||
- **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers.
|
||||
- **No scheduling or direct human rendering** — these tools mutate state only; the same-session driver and [`dsh-command-goal`](../command-goal/README.md) are independent consumers of the same domain.
|
||||
- **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together.
|
||||
|
||||
Reference in New Issue
Block a user