Merge remote-tracking branch 'origin/master' into pr-265

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/rfc/INDEX.md
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/tools/README.md
#	packages/core/tools/src/index.ts
#	packages/core/tools/src/schema.ts
#	packages/ui/acp/src/index.ts
#	packages/ui/stdio-agent/README.md
This commit is contained in:
Dudu-0223
2026-07-14 20:49:54 +08:00
715 changed files with 21094 additions and 14133 deletions

View File

@@ -30,6 +30,21 @@ function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
return { get: name => map.get(name) }
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
return { type, seq: 0, time: 0, data } as SessionEvent
}
@@ -181,12 +196,6 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
}),
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => {
const presenter = new ToolPresenter(registryOf(bashLike))
const [update] = updatesWith(presenter, evt('tool/call', {
@@ -288,10 +297,9 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
})
it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => {
// A buggy tool whose display callbacks throw must NOT fail a live turn or a
// session/load replay (docs/defensive-patterns.md "contain callback exceptions at the
// boundary"). The presenter swallows the throw, reports via onError, and
// falls back to the generic presentation.
// A buggy tool whose display callbacks throw must not fail a live turn or a session/load
// replay (docs/defensive-patterns.md "contain callback exceptions at the boundary"). The
// presenter reports the error and falls back to generic rendering.
const boom: ToolDefinition = {
name: 'boom',
description: 'b',
@@ -621,27 +629,10 @@ describe('diff-card mapping', () => {
})
describe('result-time diff card (REAL fs edit tool → tool_call_update diff blocks)', () => {
// Drive the SHIPPING fs edit tool through the bridge: the pending tool/call
// installs the call-time snippet, then the tool/result carries the tool's
// computed applied-hunk `meta`, which presentResult narrows into a `diff`
// result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses
// the REAL tool (not a stand-in) per the anti-mock convention, mirroring the
// call-side diff test above.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
// Drive the SHIPPING fs edit tool through the bridge: the pending tool/call installs the
// call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`,
// which presentResult narrows into a `diff` result card the bridge forwards as `{ type:
// 'diff' }` content blocks. The real tool is required because its result metadata is the contract.
it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
@@ -678,11 +669,10 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
})
it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => {
// A `tool_call_update.title` replaces the card header, so the result-side
// diff must relativize its title exactly as the pending card did — otherwise
// a completed absolute-path edit flips `Edit src/b.ts` back to the raw
// absolute path. The diff/location paths stay absolute (the editor opens the
// real path). Drive the REAL fs edit tool with an absolute in-workspace path.
// A `tool_call_update.title` replaces the card header, so the result-side diff must
// relativize its title exactly as the pending card did — otherwise a completed
// absolute-path edit flips `Edit src/b.ts` back to the raw absolute path. Diff and location
// paths remain absolute so the editor can open the real file.
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' })
@@ -704,11 +694,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
})
it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => {
// A synthetic tool whose presentResult yields a `diff` card with no hunks and
// no title — the shipping fs tools never emit this (edit always has a hunk;
// write always falls back to a whole-file diff), so a stand-in is the only way
// to exercise the empty-content AND absent-title branches of the result-side
// diff arm.
// Shipping edit always has a hunk and write falls back to a whole-file diff, so a synthetic
// tool is required to cover both absent-title and empty-content result branches.
const emptyDiffTool: ToolDefinition = {
name: 'writer',
description: 'writes a file',
@@ -734,19 +721,9 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
})
describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => {
// The bridge relativizes a file card's TITLE against the session workspace cwd
// (mirroring the reference adapter's toDisplayPath), while leaving locations/
// diff paths RAW. Drive it with the REAL fs tools so the title/locations come
// from the shipping presentCall, and pass an ABSOLUTE file path (which a real
// editor forwards). The presenter is pure/args-only; the cwd is known only here.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
// The bridge relativizes a file card's TITLE against the session workspace cwd (mirroring the
// reference adapter's `toDisplayPath`), while leaving location/diff paths raw. Use real fs tools
// and the absolute paths an editor supplies; presentation itself is args-only and lacks cwd.
function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] {
const presenter = new ToolPresenter(ctx.tools)
const out: SessionNotification['update'][] = []
@@ -789,10 +766,9 @@ describe('relative-path display titles (bridge relativizes the title against the
})
it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => {
// `/work/proj/..cache/x` is INSIDE the workspace — its relative form
// `..cache/x` begins with the chars `..` but is NOT a parent segment. The
// guard tests for a `..` SEGMENT, so this relativizes (matching the reference
// adapter, which accepts any target under `cwd + sep`).
// `/work/proj/..cache/x` is inside the workspace — its relative form `..cache/x` begins
// with the chars `..` but is not a parent segment. Segment-aware guarding must relativize it,
// matching targets under `cwd + sep` in the reference adapter.
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' })
expect((update as { title: string }).title).toBe('Read ..cache/x.ts')