fix(goal): align human commands with blocker reasons

This commit is contained in:
Tianyi Cui
2026-07-20 18:06:22 +08:00
parent 08b37399d1
commit 015ad420af
10 changed files with 48 additions and 61 deletions

View File

@@ -96,7 +96,7 @@ describe('dsh-acp-demo composition', () => {
sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(ctx.commands.find(handle.agent, 'acp', 'goal')).toBeUndefined()
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
await handle.dispose()
await ctx.fiber.dispose()
})

View File

@@ -121,14 +121,16 @@ describe('dsh-agent-spine-demo bundle', () => {
it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => {
const ctx = await mount({
workspaceContext: false,
agents: [{ id: SessionId('configured-goal'), provider: 'mock', model: 'mock' }],
goals: {
domain: { defaultMaxGoalRounds: 17 },
tool: { blockedAfterConsecutiveRounds: 5 },
},
})
expect(ctx.goals.resolveCreate({ objective: 'configured' })).toEqual({
objective: 'configured',
maxGoalRounds: 17,
const agent = ctx.agents.list()[0]
if (agent === undefined) throw new Error('configured goal test has no live agent')
expect(ctx.goals.create(agent, { objective: 'configured' })).toMatchObject({
objective: 'configured', maxGoalRounds: 17,
})
expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name))
.toEqual(['create_goal', 'get_goal', 'update_goal'])
@@ -199,11 +201,16 @@ describe('dsh-agent-spine-demo bundle', () => {
it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => {
const ctx = new Context()
agentCore.apply(ctx, { workspaceContext: false, goals: {} })
agentCore.apply(ctx, {
workspaceContext: false,
agents: [{ id: SessionId('defaulted-goal'), provider: 'mock', model: 'mock' }],
goals: {},
})
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.goals.resolveCreate({ objective: 'defaulted' })).toEqual({
objective: 'defaulted',
maxGoalRounds: 256,
const agent = ctx.agents.list()[0]
if (agent === undefined) throw new Error('default goal test has no live agent')
expect(ctx.goals.create(agent, { objective: 'defaulted' })).toMatchObject({
objective: 'defaulted', maxGoalRounds: 256,
})
expect(ctx.tools.get('get_goal')).toBeDefined()
await ctx.fiber.dispose()

View File

@@ -153,7 +153,7 @@ describe('dsh-stdio-demo app', () => {
expect(agent?.id).toBe(agent?.session.id)
expect(agent?.id).toMatch(/^main-session-/)
expect(agent?.session.header.cwd).toBe(process.cwd())
expect(ctx.commands.find(agent!, 'tui', 'goal')).toBeUndefined()
expect(ctx.commands.find(agent!, 'goal')).toBeUndefined()
await ctx.fiber.dispose()
})

View File

@@ -1,12 +1,12 @@
# @deepseek-ai/dsh-command-goal
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md); TUI and ACP discover and execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI and ACP execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
## Command contract
| Input | Result |
|---|---|
| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; show usage when no goal exists. |
| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; a blocked goal also shows its policy code and explanation, while no goal shows usage. |
| `/goal <objective>` | Create and arm a goal, or replace a completed goal with a fresh identity. An unfinished goal is never replaced without an explicit clear. |
| `/goal edit <objective>` | Edit the current objective without changing its phase or activation. Editing a completed goal creates a fresh active goal. |
| `/goal pause` | Pause an active goal and disarm continuation. |

View File

@@ -48,8 +48,6 @@ function phaseLabel(phase: GoalPhase): string {
case 'active': return 'active'
case 'paused': return 'paused'
case 'blocked': return 'blocked'
case 'usage-limited': return 'usage limited'
case 'budget-limited': return 'limited by round budget'
case 'complete': return 'complete'
/* v8 ignore next 2 -- GoalPhase is closed and every member is handled above */
default: return assertNever(phase, 'goal phase')
@@ -66,10 +64,7 @@ function commandHint(goal: GoalView): string {
switch (goal.phase) {
case 'paused':
case 'blocked':
case 'usage-limited':
return '/goal edit <objective>, /goal resume, /goal clear'
case 'budget-limited':
return '/goal edit <objective>, /goal clear; after the agent raises the round cap, /goal resume'
case 'complete':
return '/goal <objective>, /goal clear'
/* v8 ignore next 2 -- the active branch and every non-active phase are handled above */
@@ -79,11 +74,16 @@ function commandHint(goal: GoalView): string {
/** Render direct UI output without exposing compare-and-set internals. */
function renderGoal(title: string, goal: GoalView): CommandResult {
const reason = goal.phase === 'blocked' ? goal.blockedReason : undefined
/* v8 ignore next -- durable replay guarantees every blocked goal carries its validated reason */
if (goal.phase === 'blocked' && reason === undefined) throw new TypeError('blocked goal is missing its reason')
const blocker = reason === undefined ? [] : [`Blocker: ${reason.code}: ${reason.message}`]
return {
kind: 'success',
text: [
title,
`Status: ${phaseLabel(goal.phase)}`,
...blocker,
`Objective: ${goal.objective}`,
`Rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`,
`Activation: ${goal.activation}`,
@@ -159,7 +159,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman
}
}
/** Register the Codex-shaped `/goal` human command on TUI and ACP surfaces. */
/** Register the Codex-shaped `/goal` command for every composed command adapter. */
export function apply(ctx: Context): void {
ctx.commands.register({
name: 'goal',

View File

@@ -74,7 +74,6 @@ async function harness(): Promise<Harness> {
async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
const result = await test.ctx.commands.execute(
test.agent,
'tui',
`/goal${suffix}`,
new AbortController().signal,
)
@@ -87,20 +86,8 @@ function ref(goal: NonNullable<ReturnType<GoalService['get']>>): GoalRef {
return { id: goal.id, revision: goal.revision }
}
/** Append one admitted goal round for budget-limited presentation coverage. */
function appendRound(test: Harness, goal: NonNullable<ReturnType<GoalService['get']>>): void {
const source = { kind: 'goal', goalId: goal.id, revision: goal.revision, round: 1 } as const
const turn = nextTurn(test.session)
test.session.append('turn/start', { turn, trigger: { kind: 'message', source } })
test.session.append('user/message', {
content: [{ type: 'text', text: 'goal round' }],
source,
}, { surfaceOp: 'append' })
test.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
describe('@deepseek-ai/dsh-command-goal registration', () => {
it('registers one global TUI/ACP command with Loader-safe exports and disposes it', async () => {
it('registers one global command with Loader-safe exports and disposes it', async () => {
const test = await harness()
expect(commandGoal.name).toBe('command-goal')
expect(commandGoal.inject).toEqual(['commands', 'goals'])
@@ -108,16 +95,15 @@ describe('@deepseek-ai/dsh-command-goal registration', () => {
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(commandGoal)).toBe(commandGoal)
expect(test.ctx.commands.list(test.agent, 'tui')).toContainEqual({
expect(test.ctx.commands.list(test.agent)).toContainEqual({
name: 'goal',
description: 'set or view the goal for a long-running task',
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
surfaces: ['tui', 'acp'],
})
expect(test.ctx.commands.find(test.agent, 'acp', 'goal')).toBeDefined()
expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
await test.plugin.dispose()
expect(test.ctx.commands.find(test.agent, 'tui', 'goal')).toBeUndefined()
expect(test.ctx.commands.find(test.agent, 'goal')).toBeUndefined()
})
})
@@ -227,22 +213,16 @@ describe('/goal human command', () => {
expect((await run(test)).text).toContain('Status: paused')
goal = test.ctx.goals.resume(test.agent, ref(goal))
goal = test.ctx.goals.block(test.agent, ref(goal))
expect((await run(test)).text).toContain('Status: blocked')
goal = test.ctx.goals.block(test.agent, ref(goal), {
code: 'upstream-unavailable',
message: 'Provider unavailable',
})
const blocked = await run(test)
expect(blocked.text).toContain('Status: blocked')
expect(blocked.text).toContain('Blocker: upstream-unavailable: Provider unavailable')
goal = test.ctx.goals.resume(test.agent, ref(goal))
goal = test.ctx.goals.markUsageLimited(test.agent, ref(goal))
expect((await run(test)).text).toContain('Status: usage limited')
goal = test.ctx.goals.resume(test.agent, ref(goal))
appendRound(test, goal)
goal = test.ctx.goals.get(test.agent)!
goal = test.ctx.goals.markBudgetLimited(test.agent, ref(goal))
const limited = await run(test)
expect(limited.text).toContain('Status: limited by round budget')
expect(limited.text).toContain('after the agent raises the round cap, /goal resume')
goal = test.ctx.goals.complete(test.agent, ref(goal))
test.ctx.goals.complete(test.agent, ref(goal))
const complete = await run(test)
expect(complete.text).toContain('Status: complete')
expect(complete.text).toContain('Commands: /goal <objective>, /goal clear')