Merge remote-tracking branch 'origin/master' into worktree/acp-automation-protocol

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
#	.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md
#	.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md
#	.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md
#	.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md
#	.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md
#	.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md
#	.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md
#	docs/architecture.i18n.yaml
#	docs/cookbook/extension-cookbook.i18n.yaml
#	docs/cookbook/extension-cookbook.md
#	docs/cookbook/extension-cookbook.zh.md
#	docs/core-data-structures/approval.md
#	docs/core-data-structures/user-interaction.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	docs/testing.md
#	docs/tool-catalog.md
#	examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	packages/goal/tool-goal/README.md
#	packages/ui/acp/README.md
#	packages/ui/acp/acp-feature-support.md
#	packages/ui/acp/src/index.ts
#	packages/ui/acp/tests/bridge.spec.ts
#	packages/ui/acp/tests/dispose.spec.ts
#	packages/ui/acp/tests/edges.spec.ts
#	packages/ui/acp/tests/turns.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-24 23:43:10 +08:00
829 changed files with 23607 additions and 1815 deletions
+3 -3
View File
@@ -6,9 +6,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation.
- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution.
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`.
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. Strict-schema empty-string and zero fillers count as omitted, while meaningful values remain limited to their action.
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. UI clients receive pure generic cards: read for `get_goal`, other for mutations.
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. UI clients receive pure generic cards: read for `get_goal`, other for mutations. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input.
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.
@@ -18,7 +18,7 @@ An autonomous goal round that successfully reports `complete` or `blocked` contr
Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does.
`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
`{ kind: 'user' }` is a host attestation. `Agent.followup()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately.
+1 -1
View File
@@ -64,7 +64,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE
/**
* Whether host-attested human input appears in the current root-agent turn.
* An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human
* An omitted `Agent.followup()` / `steer()` source resolves to `user`, so non-human
* producers must supply their own source rather than inheriting this authority.
*/
function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {
+21 -7
View File
@@ -132,6 +132,16 @@ function resolveConfig(config: Config): ResolvedConfig {
return { blockedAfterConsecutiveRounds: blockedAfter }
}
/** Whether optional text is meaningful rather than a strict-schema empty filler. */
function hasText(value: string | undefined): value is string {
return value !== undefined && value !== ''
}
/** Whether an optional round cap is meaningful rather than a strict-schema zero filler. */
function hasRoundCap(value: number | undefined): value is number {
return value !== undefined && value !== 0
}
/** Build the exact compare-and-set ref from model arguments. */
function goalRef(goalId: string, revision: number): GoalRef {
if (goalId.length === 0 || goalId !== goalId.trim()
@@ -271,12 +281,12 @@ export function apply(ctx: Context, config: Config): void {
const execution = goalToolExecution(ctx, exec)
const ref = goalRef(args.goal_id, args.revision)
const replacements = {
...args.objective === undefined ? {} : { objective: args.objective },
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
...hasText(args.objective) ? { objective: args.objective } : {},
...hasRoundCap(args.max_goal_rounds) ? { maxGoalRounds: args.max_goal_rounds } : {},
}
if (args.action === 'edit') {
requireDirectHuman(ctx, execution)
if (args.blocked_reason !== undefined) {
if (hasText(args.blocked_reason)) {
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
}
const goal = ctx.goals.edit(execution.agent, ref, replacements)
@@ -285,7 +295,7 @@ export function apply(ctx: Context, config: Config): void {
}
if (args.action === 'pause' || args.action === 'resume') {
requireDirectHuman(ctx, execution)
if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) {
if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds) || hasText(args.blocked_reason)) {
throw new HarnessError(
'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked',
'GOAL_TOOL_INVALID_UPDATE',
@@ -298,13 +308,13 @@ export function apply(ctx: Context, config: Config): void {
return Promise.resolve(goalValue(goal))
}
const authority = completionAuthority(ctx, execution)
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds)) {
throw new HarnessError(
'objective and max_goal_rounds are valid only with action edit',
'GOAL_TOOL_INVALID_UPDATE',
)
}
if (args.action === 'complete' && args.blocked_reason !== undefined) {
if (args.action === 'complete' && hasText(args.blocked_reason)) {
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
}
if (args.action === 'blocked'
@@ -331,7 +341,11 @@ export function apply(ctx: Context, config: Config): void {
presentCall: args => present(
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,
'other',
args.blocked_reason ?? args.objective ?? args.goal_id,
hasText(args.blocked_reason)
? args.blocked_reason
: hasText(args.objective)
? args.objective
: hasRoundCap(args.max_goal_rounds) ? args.max_goal_rounds : args.goal_id,
),
}))
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
@@ -31,16 +31,19 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
session,
get status() { return status },
ctx: new Context(),
send() {},
steer() {},
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content: ContentBlock[], options?: InjectOptions) {
const source = options?.source ?? { kind: 'user' }
session.append('context/message', {
const source = options?.source ?? { kind: 'plugin', plugin: '' }
session.append('user/message', {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
}, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
send: () => AgentMessageId('stub'),
cancel() {},
whenIdle() { return Promise.resolve() },
}
@@ -142,8 +145,17 @@ describe('goal tool registration and presentation', () => {
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.',
})).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'edit',
objective: 'ship', max_goal_rounds: 0, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 'ship' })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'edit',
objective: '', max_goal_rounds: 8, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 8 })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'resume',
objective: '', max_goal_rounds: 0, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' })
expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined()
})
@@ -228,7 +240,9 @@ describe('goal tool execution authority', () => {
it('rejects stale agent objects and agents outside running status through the executor', async () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
const stale = { ...root.agent }
// A distinct agent object over root's exact session: same id, not the live
// registered instance, so the executor must reject it.
const stale = stubAgent('goal-tool-stale', root.agent.session).agent
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
@@ -423,6 +437,77 @@ describe('goal tool state transitions', () => {
expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
})
it('accepts only empty fillers in fields unused by the selected action', async () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
let goal = ctx.goals.create(root.agent, { objective: 'valid' })
const edited = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'edit',
objective: 'edited',
max_goal_rounds: 0,
blocked_reason: '',
}, root.agent)
expect(resultGoal(edited)).toMatchObject({ objective: 'edited' })
goal = ctx.goals.get(root.agent)!
const capped = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'edit',
objective: '',
max_goal_rounds: 8,
blocked_reason: '',
}, root.agent)
expect(resultGoal(capped)).toMatchObject({ objective: 'edited', maxGoalRounds: 8 })
goal = ctx.goals.get(root.agent)!
const paused = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'pause',
objective: '',
max_goal_rounds: 0,
blocked_reason: '',
}, root.agent)
expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' })
goal = ctx.goals.get(root.agent)!
const resumed = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'resume',
objective: '',
max_goal_rounds: 0,
blocked_reason: '',
}, root.agent)
expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' })
goal = ctx.goals.get(root.agent)!
const blocked = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'blocked',
objective: '',
max_goal_rounds: 0,
blocked_reason: 'actual blocker',
}, root.agent)
expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' })
goal = ctx.goals.resume(root.agent, { id: goal.id, revision: goal.revision + 1 })
const complete = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'complete',
objective: '',
max_goal_rounds: 0,
blocked_reason: '',
}, root.agent)
expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited' })
})
it('allows exact goal rounds to complete but not edit or pause', async () => {
const { ctx, root } = await harness()
const humanTurn = openTurn(root, { kind: 'user' })