feat(goal): add human goal command

This commit is contained in:
Tianyi Cui
2026-07-19 23:55:33 +08:00
parent 400b4658ef
commit 207692bc16
114 changed files with 1831 additions and 88 deletions

View File

@@ -523,7 +523,7 @@ describe('steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
})
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => {
// Assert the same-turn shape; content alone cannot distinguish re-enqueue.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),

View File

@@ -4,10 +4,10 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) |
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + command registry + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` |
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with an opt-in persisted-goal stack |
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + persisted goals + `/goal` command + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + command registry + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + persisted goals + `/goal` command + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.

View File

@@ -12,6 +12,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
| `@deepseek-ai/dsh-commands` | the human-command registry used for ACP discovery and direct slash dispatch |
| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
@@ -36,6 +37,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor.
@@ -54,7 +56,7 @@ All diagnostics go to **stderr** — stdout is the protocol.
## Model Experience
Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself.
Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, goal tools, and message history. Direct `/goal` input and output remain outside the model, while accepted mutations append domain-owned model-visible snapshots.
#### KV Cache effect

View File

@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
@@ -49,6 +50,7 @@
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -13,6 +13,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
@@ -56,6 +57,8 @@ export interface Config {
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
}
// Each front door owns a complete, directly readable config schema; extracting
@@ -77,6 +80,7 @@ export const Config: z<Config> = z.object({
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
})
/* jscpd:ignore-end */
@@ -88,8 +92,10 @@ export const Config: z<Config> = z.object({
* from the provider/model pair. No logger, no `hmr` — stdout stays pure.
*/
export function apply(ctx: Context, config: Config): void {
const goals = config.goals ?? {}
ctx.plugin(CommandService)
ctx.plugin(agentCore, agentCore.pickSpineConfig(config))
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
ctx.plugin(acp, { provider: config.provider, model: config.model })

View File

@@ -77,11 +77,30 @@ describe('dsh-acp-demo composition', () => {
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
expect(ctx.get('goals')).toBeDefined()
expect(ctx.get('tools')?.get('get_goal')).toBeDefined()
// No pre-created agents — ACP session/new creates them on demand.
expect(ctx.get('agents')!.list()).toHaveLength(0)
await ctx.fiber.dispose()
})
it('can explicitly omit the persisted-goal stack and its command', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
goals: false,
workspaceContext: false,
})
expect(ctx.get('goals')).toBeUndefined()
const handle = await ctx.agents.create({
sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(ctx.commands.find(handle.agent, 'acp', 'goal')).toBeUndefined()
await handle.dispose()
await ctx.fiber.dispose()
})
it('defaults the persistence root when omitted', async () => {
// Exercises the `DEFAULT_PERSISTENCE_ROOT` fallback for a direct-apply caller that
// bypasses the schema's `.default(...)`: call `apply` directly (not via
@@ -179,7 +198,17 @@ describe('dsh-acp-demo composition', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill', 'task_kill', 'task_list', 'task_output'])
expect(assembly.tools.map(tool => tool.name)).toEqual([
'zulu',
'alpha',
'create_goal',
'get_goal',
'skill',
'task_kill',
'task_list',
'task_output',
'update_goal',
])
await ctx.fiber.dispose()
})

View File

@@ -26,6 +26,9 @@
{
"path": "../../ui/commands"
},
{
"path": "../../goal/command-goal"
},
{
"path": "../../core/agent"
},

View File

@@ -17,6 +17,9 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-skill skill provider registry
@deepseek-ai/dsh-skill-local local filesystem skill provider
@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events
@deepseek-ai/dsh-goal optional persisted same-session goal domain
@deepseek-ai/dsh-tool-goal optional model-facing goal controls
@deepseek-ai/dsh-goal-session optional same-session goal-round driver
@deepseek-ai/dsh-tasks generic background-task registry
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash schema
@@ -42,11 +45,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? }
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include
@@ -54,7 +57,7 @@ A YAML include can deduplicate config but cannot own a bin or provide front-door
## Model Experience
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, which this bundle mounts without adding model-bound wrapper content.
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, plus `dsh-tool-goal` and goal-round prompts when `goals` is enabled. The bundle adds no model-bound wrapper content of its own.
#### KV Cache effect
@@ -62,5 +65,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent-spine-demo",
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)",
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin with optional persisted goals",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -25,6 +25,8 @@
"@cordisjs/plugin-timer": "^1.1.2",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-goal-session": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
@@ -35,6 +37,7 @@
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
"@deepseek-ai/dsh-tool-goal": "^0.0.1",
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
"@deepseek-ai/dsh-tool-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -44,6 +47,8 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
@@ -55,6 +60,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -1,6 +1,6 @@
/**
* Default executor-less, UI-less agent spine. It bundles the common services,
* background-task registry and controls, concrete loop, local skill and
* background-task registry and controls, optional persisted goals, concrete loop, local skill and
* workspace-context providers, and model-facing bash/skill consumers;
* deployments still choose the LLM adapter, bash executor, and presentation.
* The plugin intentionally exposes named exports only because Loader default
@@ -18,6 +18,9 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal'
import * as goalSession from '@deepseek-ai/dsh-goal-session'
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as invariants from '@deepseek-ai/dsh-invariants'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
@@ -41,6 +44,14 @@ export interface SkillConfig {
tool?: toolSkill.Config
}
/** Persisted goal domain, model-tool policy, and same-session driver config. */
export interface GoalConfig {
/** Goal-domain creation defaults. */
domain?: GoalDomainConfig
/** Model-facing goal-tool authority policy. */
tool?: toolGoal.Config
}
/**
* Bundle config: each field forwarded verbatim to the child that owns it —
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
@@ -50,7 +61,8 @@ export interface SkillConfig {
* `dshHome` to bash environment and local skill discovery, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
* plugins this bundle owns. Owner schemas supply defaults for optional input;
* plugins this bundle owns. `goals` opts into and configures the persisted goal
* domain plus its model tool and same-session driver. Owner schemas supply defaults for optional input;
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
@@ -77,6 +89,8 @@ export interface Config {
toolBash?: toolBash.Config
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
/** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */
goals?: GoalConfig | false
}
/** The skill config schema exported for app packages that forward `skills`. */
@@ -93,6 +107,12 @@ export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
export const ToolTasksConfigSchema: z<toolTasks.Config> = toolTasks.Config
/** The persisted-goal config schema exported for app packages that opt in. */
export const GoalConfigSchema: z<GoalConfig> = z.object({
domain: GoalService.Config,
tool: toolGoal.Config,
})
/** Intersect the owners' schemas so validation + defaulting stay identical. */
export const Config = z.intersect([
AgentLoop.Config,
@@ -104,7 +124,8 @@ export const Config = z.intersect([
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks'>>,
goals: z.union([z.const(false), GoalConfigSchema]),
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'goals'>>,
]) as unknown as z<Config>
/**
@@ -123,6 +144,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
...config.goals !== undefined ? { goals: config.goals } : {},
}
}
@@ -159,6 +181,11 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
}
ctx.plugin(AgentRegistry)
if (config.goals !== undefined && config.goals !== false) {
ctx.plugin(GoalService, config.goals.domain ?? {})
ctx.plugin(toolGoal, config.goals.tool ?? {})
ctx.plugin(goalSession)
}
ctx.plugin(TaskService)
ctx.plugin(invariants)
ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))

View File

@@ -114,6 +114,33 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('tasks')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('goals')).toBeUndefined()
await ctx.fiber.dispose()
})
it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => {
const ctx = await mount({
workspaceContext: false,
goals: {
domain: { defaultMaxGoalRounds: 17 },
tool: { blockedAfterConsecutiveRounds: 5 },
},
})
expect(ctx.goals.resolveCreate({ objective: 'configured' })).toEqual({
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'])
expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:goal')?.text)
.toContain('at least 5 consecutive goal rounds')
await ctx.fiber.dispose()
})
it('accepts an explicit false goal composition without mounting it', async () => {
const ctx = await mount({ workspaceContext: false, goals: false })
expect(ctx.get('goals')).toBeUndefined()
expect(ctx.tools.get('get_goal')).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -170,6 +197,18 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => {
const ctx = new Context()
agentCore.apply(ctx, { workspaceContext: false, goals: {} })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.goals.resolveCreate({ objective: 'defaulted' })).toEqual({
objective: 'defaulted',
maxGoalRounds: 256,
})
expect(ctx.tools.get('get_goal')).toBeDefined()
await ctx.fiber.dispose()
})
it('loads workspace instructions into requests through the bundled spine', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-workspace-context-'))
try {

View File

@@ -41,6 +41,15 @@
{
"path": "../../core/agent"
},
{
"path": "../../goal/goal"
},
{
"path": "../../goal/tool-goal"
},
{
"path": "../../goal/goal-session"
},
{
"path": "../../context/workspace-context"
},

View File

@@ -12,6 +12,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` |
| `@deepseek-ai/dsh-commands` | the human-command registry consumed by the TUI front door and optional command plugins |
| `@deepseek-ai/dsh-command-goal` | the direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
@@ -37,6 +38,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `welcome` | `ready.` | terminal banner / TUI subtitle |
| `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config |
@@ -82,7 +84,7 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo —
#### What the model sees
Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each ordinary terminal submission becomes a user message; submissions made while the agent runs steer the active turn. TUI commands and their results remain outside model context.
Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, visible tools, and the enabled goal policy/tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each ordinary terminal submission becomes a user message; submissions made while the agent runs steer the active turn. TUI commands and their direct results remain outside model context, while accepted `/goal` mutations append the goal domain's model-visible snapshot.
#### Token effect
@@ -109,5 +111,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **One pre-created `main` agent drives the selected terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation.
- **Direct commands require TUI mode** — the line-oriented fallback does not consume `ctx.commands`; an ordinary `/goal` prompt there may instead be interpreted through the model-facing goal tools.
- **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package.
- **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer.

View File

@@ -37,6 +37,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
@@ -58,6 +59,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -17,6 +17,7 @@ import z from 'schemastery'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -100,6 +101,8 @@ export interface Config {
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/**
* If set, the pre-created agent RESUMES this persisted session id instead of
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
@@ -127,6 +130,7 @@ export const Config: z<Config> = z.object({
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
@@ -145,8 +149,10 @@ export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean)
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
const mode = resolveTerminalMode(config.ui, isTTY)
const goals = config.goals ?? {}
if (mode === 'readline') ctx.plugin(ConsoleExporter)
ctx.plugin(CommandService)
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
ctx.plugin(UserInteractionService)
if (mode === 'tui') {
@@ -163,6 +169,7 @@ export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean)
}
ctx.plugin(agentCore, {
...agentCore.pickSpineConfig(config),
goals,
agents: [{
id: SessionId('main'),
provider: config.provider,

View File

@@ -90,6 +90,7 @@ describe('dsh-stdio-demo app', () => {
ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } },
}, true)
expect(calls.map(call => call.name)).toContain('ui-tui')
expect(calls.map(call => call.name)).toContain('command-goal')
expect(calls.map(call => call.name)).not.toContain('ui-stdio')
expect(calls.map(call => call.name)).not.toContain('ConsoleExporter')
const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string }
@@ -99,6 +100,7 @@ describe('dsh-stdio-demo app', () => {
agents: Array<{ id: string; sessionId?: string; resumeSessionId?: string }>
}
expect(spineConfig.agents[0]).toMatchObject({ id: 'main', sessionId: tuiConfig.sessionId })
expect(spineConfig).toMatchObject({ goals: {} })
calls.length = 0
stdioAgent.composeTerminalApp(ctx, {
@@ -116,11 +118,13 @@ describe('dsh-stdio-demo app', () => {
calls.length = 0
stdioAgent.composeTerminalApp(ctx, {
provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' },
provider: 'mock', model: 'mock', workspaceContext: false, goals: false, ui: { mode: 'readline' },
}, false)
expect(calls.map(call => call.name)).toContain('ui-stdio')
expect(calls.map(call => call.name)).toContain('ConsoleExporter')
expect(calls.map(call => call.name)).not.toContain('ui-tui')
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls.find(call => call.name === 'agent-spine-demo')?.config).toMatchObject({ goals: false })
})
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
@@ -131,6 +135,8 @@ describe('dsh-stdio-demo app', () => {
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
expect(ctx.get('goals')).toBeDefined()
expect(ctx.get('tools')?.get('get_goal')).toBeDefined()
// The sole pre-created agent the UI drives. `main` is its stable config
// label; each fresh process mints a durable combined agent/session id.
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
@@ -139,6 +145,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')).toBeDefined()
await ctx.fiber.dispose()
})
@@ -273,7 +280,18 @@ describe('dsh-stdio-demo app', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill', 'task_kill', 'task_list', 'task_output'])
expect(assembly.tools.map(tool => tool.name)).toEqual([
'zulu',
'alpha',
'ask_user_question',
'create_goal',
'get_goal',
'skill',
'task_kill',
'task_list',
'task_output',
'update_goal',
])
await ctx.fiber.dispose()
})

View File

@@ -32,6 +32,9 @@
{
"path": "../../ui/commands"
},
{
"path": "../../goal/command-goal"
},
{
"path": "../agent-spine-demo"
},

View File

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

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

View 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"
}
}

View 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),
})
}

View 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')
})
})

View 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"
}
]
}

View File

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