Merge origin/master into codex/invariant-service-seam
# Conflicts: # docs/config-catalog.md # docs/event-producer-consumer.md # docs/module-graph.md # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/package.json # packages/examples/agent-spine-demo/src/index.ts # packages/examples/tui-demo/package.json # packages/support/invariants/src/scoped-events.generated.ts # packages/ui/acp/package.json # packages/ui/tui/package.json # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
This commit is contained in:
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), 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; 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. |
|
||||
| `/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 stable direct command errors without exposing branded ids or revisions. 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 TUI 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 headless CLI and JSON-RPC adapters do not consume `ctx.commands`. Ordinary human prompts can still authorize the model-facing goal tools when those are composed.
|
||||
45
packages/goal/command-goal/package.json
Normal file
45
packages/goal/command-goal/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.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",
|
||||
"@deepseek-ai/dsh-invariants": "^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-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
170
packages/goal/command-goal/src/index.ts
Normal file
170
packages/goal/command-goal/src/index.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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 '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':
|
||||
return '/goal edit <objective>, /goal resume, /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 {
|
||||
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}`,
|
||||
'',
|
||||
`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: 'The goal command is not valid for the current state. Run /goal to view available commands.',
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the Codex-shaped `/goal` command for every composed command adapter. */
|
||||
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),
|
||||
})
|
||||
}
|
||||
30
packages/goal/command-goal/src/invariant.ts
Normal file
30
packages/goal/command-goal/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-command-goal`.
|
||||
* @module @deepseek-ai/dsh-command-goal/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-command-goal'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'command-goal-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this command adapter owns no event stream or state projection; accepted
|
||||
* mutations are checked by the goal domain and command dispatch behavior is covered by package tests.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
235
packages/goal/command-goal/tests/command-goal.spec.ts
Normal file
235
packages/goal/command-goal/tests/command-goal.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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?.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,
|
||||
`/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 }
|
||||
}
|
||||
|
||||
describe('@deepseek-ai/dsh-command-goal registration', () => {
|
||||
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'])
|
||||
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)).toContainEqual({
|
||||
name: 'goal',
|
||||
description: 'set or view the goal for a long-running task',
|
||||
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
|
||||
})
|
||||
expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
|
||||
|
||||
await test.plugin.dispose()
|
||||
expect(test.ctx.commands.find(test.agent, '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).toEqual({
|
||||
kind: 'error',
|
||||
text: 'The goal command is not valid for the current state. Run /goal to view available commands.',
|
||||
})
|
||||
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), {
|
||||
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))
|
||||
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')
|
||||
})
|
||||
})
|
||||
27
packages/goal/command-goal/tsconfig.json
Normal file
27
packages/goal/command-goal/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../goal"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user