Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts: # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md # .agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml # .agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml # docs/architecture.i18n.yaml # docs/cookbook/adding-a-tool.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/session.i18n.yaml # docs/core-data-structures/tools.i18n.yaml # docs/event-producer-consumer.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/permission-switching/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode-reject/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # packages/context/session-reference/README.md # packages/core/agent-loop/tests/agent.spec.ts # packages/hooks/hooks-claude/tests/coverage-cases.ts # packages/host/runtime/tests/host-runtime.spec.ts # packages/llm/llm-retry/tests/retry.spec.ts # packages/session-persistence/session-persistence/src/coordinator.ts # packages/support/acp-snapshot/README.md # packages/support/acp-snapshot/src/normalize.ts # packages/ui/acp/acp-feature-support.md # packages/ui/acp/src/codec.ts # packages/ui/acp/src/index.ts # packages/ui/acp/tests/bridge.spec.ts # packages/ui/acp/tests/codec.spec.ts # packages/ui/acp/tests/config-options.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/ui/acp/tests/stream-update.spec.ts # packages/ui/acp/tests/turns.spec.ts
This commit is contained in:
@@ -472,8 +472,8 @@ export async function writeFileAtomic(
|
||||
try {
|
||||
await replaceFile(absolutePath, tempPath)
|
||||
} catch (error: unknown) {
|
||||
// Preserve the old behavior when an external actor removes the observed target during
|
||||
// staging: the temp already carries that target's protected DACL, so rename recreates it.
|
||||
// If the observed target disappears during staging, the protected DACL
|
||||
// already copied to the temp remains authoritative for recreation.
|
||||
if (!isENOENT(error)) throw error
|
||||
await rename(tempPath, absolutePath)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { structuredPatch } from 'diff'
|
||||
import type { FileDiff } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Context lines shown on each side of an applied hunk (matches claude-agent-acp). */
|
||||
/** Context lines shown on each side of an applied hunk. */
|
||||
export const DIFF_CONTEXT = 3
|
||||
|
||||
/**
|
||||
@@ -15,8 +15,7 @@ export const DIFF_CONTEXT = 3
|
||||
* contextual-diff hunks. Attached opaquely (as `unknown`) on the tool result and
|
||||
* persisted with the session log — it must be JSON-serializable (the session
|
||||
* validates this at `append`), so `presentResult` reproduces the diff card on
|
||||
* replay. The producing tool owns this shape; the bridge only sees the opaque
|
||||
* `meta` and the tool narrows it back via {@link diffsFromMeta}.
|
||||
* replay. The producing tool owns and narrows this opaque shape.
|
||||
*/
|
||||
export type FsDiffMeta = { diffs: FileDiff[] }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Derive the working directory a filesystem tool resolves relative paths against: the calling
|
||||
* agent's per-session workspace (`exec.agent.session.header.cwd`), so each ACP session's
|
||||
* agent's per-session workspace (`exec.agent.session.header.cwd`), so each session's
|
||||
* `read`/`write`/`edit` act on ITS workspace, not the server's launch dir — mirroring how
|
||||
* `dsh-tool-bash` defaults a bash `workdir` to the session cwd.
|
||||
* Non-agent calls return `undefined`, leaving the fallback in the provider rather than reading
|
||||
|
||||
@@ -125,9 +125,8 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
after: outcome.after,
|
||||
}
|
||||
},
|
||||
// Pure display: a diff card (an editor renders write as a new-file / full- replace diff).
|
||||
// `oldText: null` — a call-time presenter has no access to the file's prior content, so
|
||||
// even an overwrite renders new-file style, matching claude-agent-acp.
|
||||
// Pure display: a diff card. A call-time presenter has no access to prior
|
||||
// file content, so `oldText: null` also represents an overwrite here.
|
||||
presentCall(args): DiffCallView {
|
||||
return {
|
||||
card: 'diff',
|
||||
@@ -136,10 +135,9 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
locations: [{ path: args.file_path }],
|
||||
}
|
||||
},
|
||||
// Result-time display: a `diff` card so the completed `tool_call_update` re-installs the
|
||||
// diff rather than the model-facing result text (an ACP `tool_call_update.content` REPLACES
|
||||
// the call's content, so a text result would clobber the pending diff card). Overwrites use
|
||||
// applied metadata; creates and identical overwrites use the replay-safe args fallback.
|
||||
// Result-time display repeats the diff because completed views replace the
|
||||
// pending view. Overwrites use applied metadata; creates and identical
|
||||
// overwrites use the replay-safe args fallback.
|
||||
presentResult(args, result: ToolResult): DiffResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const diffs = diffsFromMeta(result.meta)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Unit tests for the result-time contextual-diff computation (`src/diff.ts`):
|
||||
* the pure before/after → {@link FileDiff}[] hunk builder and the defensive
|
||||
* `meta` narrowing. These pin the exact hunk reconstruction (context lines,
|
||||
* multi-hunk replaceAll, pure insertion/deletion, no-op) the ACP bridge renders.
|
||||
* multi-hunk replaceAll, pure insertion/deletion, no-op) that UIs render.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -6,14 +6,7 @@ import type { Context } from 'cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { fsHarness, waitForIdle } from './harness.ts'
|
||||
|
||||
/**
|
||||
* With-key smoke for the filesystem tools: a REAL model drives the REAL
|
||||
* read/write/edit tools (over the real local backend + policy gate), and we
|
||||
* verify the WORLD — the file on disk — not the agent's self-report. This is the
|
||||
* "green units, broken product" guard: mocks prove the plumbing, only a real
|
||||
* model proves the tools actually work end-to-end. Key-gated (self-skips without
|
||||
* DEEPSEEK_API_KEY).
|
||||
*/
|
||||
/** Key-gated smoke for a real model driving the local read/write/edit tools. */
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
@@ -42,7 +35,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
|
||||
+ 'Tell me when done.' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Verify the WORLD: the edit landed on disk.
|
||||
// Assert the filesystem effect independently of the model response.
|
||||
const content = await readFile(join(workdir, 'note.txt'), 'utf8')
|
||||
expect(content).toContain('status: final')
|
||||
expect(content).not.toContain('draft')
|
||||
|
||||
@@ -284,8 +284,8 @@ describe('bare provider (no dsh-fs-policy)', () => {
|
||||
})
|
||||
|
||||
// Per-session cwd: a relative file_path resolves against the calling session's workspace
|
||||
// (`exec.agent.session.header.cwd`), not the backend's config.cwd — so an ACP editor's
|
||||
// per-session dir wins, matching dsh-tool-bash.
|
||||
// (`exec.agent.session.header.cwd`), not the backend's config.cwd, so the
|
||||
// caller-selected session workspace wins, matching dsh-tool-bash.
|
||||
describe('per-session cwd', () => {
|
||||
let sessionDir: string
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, sep } from 'node:path'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -425,8 +425,8 @@ describe('edit tool', () => {
|
||||
})
|
||||
|
||||
describe('tool-owned presentation (pure presentCall)', () => {
|
||||
// presentCall is a pure display function of args (no I/O); it drives the ACP
|
||||
// card's title/kind and the `locations` an editor follows along to.
|
||||
// presentCall is a pure display function of args (no I/O); it drives the
|
||||
// card's title/kind and the `locations` a UI follows along to.
|
||||
const presentCall = async (name: string, args: unknown) => {
|
||||
const { ctx } = await setup()
|
||||
return ctx.tools.get(name)?.presentCall?.(args)
|
||||
@@ -478,7 +478,7 @@ describe('tool-owned presentation (pure presentCall)', () => {
|
||||
|
||||
describe('result-time contextual diff (meta + presentResult)', () => {
|
||||
// An edit records the applied contextual hunk on `tool/result` meta, and the tool's
|
||||
// presentResult narrows it back into a `diff` result card the bridge renders.
|
||||
// presentResult narrows it back into a replayable `diff` result card.
|
||||
const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n'
|
||||
|
||||
it('edit: execute attaches the applied hunk as meta { diffs }', async () => {
|
||||
@@ -519,9 +519,8 @@ describe('result-time contextual diff (meta + presentResult)', () => {
|
||||
})
|
||||
|
||||
it('write CREATE: an empty applied-diff projection still falls back to the whole-file diff card', async () => {
|
||||
// A create has no prior content, yet the completed card must be a `diff` — an
|
||||
// ACP tool_call_update.content REPLACES the call's content, so a non-diff result would
|
||||
// clobber the pending new-file diff.
|
||||
// A create has no prior content, yet the completed replacement view must
|
||||
// remain a diff instead of clobbering the pending new-file diff with text.
|
||||
const { ctx } = await setup()
|
||||
const session = { header: {} }
|
||||
const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })
|
||||
@@ -729,13 +728,13 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
it('a plain write stamps the default mode with the calling session root', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
|
||||
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }])
|
||||
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }])
|
||||
})
|
||||
|
||||
it('a standing session override folds onto the stamp', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
|
||||
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }])
|
||||
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }])
|
||||
})
|
||||
|
||||
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
|
||||
@@ -768,7 +767,7 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
agent: escalationAgent() as never,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }])
|
||||
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }])
|
||||
})
|
||||
|
||||
it('a rejected escalation fails closed with its own text and never mutates', async () => {
|
||||
|
||||
Reference in New Issue
Block a user