From e20f560992417fdfc15bbd5d5201838272463d69 Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 14 Aug 2026 13:06:21 +0800 Subject: [PATCH 01/56] feat(python-sdk): support bundled preset runtime dependencies --- packages/fs/tool-fs-search/src/search-core.ts | 18 ++- .../shell/tool-bash-persistent/src/index.ts | 3 +- .../tests/loader-composition.spec.ts | 6 +- .../tool-bash-persistent/tests/tools.spec.ts | 4 +- .../subprocess-local/src/process-inspector.ts | 80 ++++++++++-- .../subprocess-local/src/terminal.ts | 16 ++- .../tests/process-inspector.spec.ts | 35 +++++ .../terminal/terminal-bash/src/sanitize.ts | 5 +- packages/terminal/terminal/src/index.ts | 3 + pnpm-lock.yaml | 18 +++ python/sdk-runtime/hatch_build.py | 2 +- python/sdk-runtime/package.json | 6 + .../src/deepseek_harness_runtime/__init__.py | 10 +- python/sdk/tests/test_release_version.py | 4 + scripts/build-exe-for-python-sdk.ts | 29 ++++- scripts/build-python-release.py | 3 +- scripts/smoke-python-runtime.py | 95 +++++++++++++- scripts/verify-runtime-closure.ts | 123 ++++++++++++++++-- 18 files changed, 410 insertions(+), 50 deletions(-) diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 1dddb99373..e4580f7301 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -19,6 +19,7 @@ * @module @deepseek-ai/dsh-tool-fs-search/search-core */ +import { existsSync } from 'node:fs' import { isAbsolute, relative, sep } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -158,18 +159,21 @@ let rgPathPromise: Promise | undefined /** * The packaged ripgrep binary path, resolved lazily once per process. * - * `@vscode/ripgrep` resolves its platform package (`@vscode/ripgrep- - * -`) at module evaluation, so a static import would turn a missing or - * corrupt platform package (`pnpm install --omit=optional`, partial install) - * into a failure of the whole Loader composition. Resolving at the call - * boundary keeps that failure at the first search call as `SEARCH_FAILED` — - * the package's documented no-load-time-probe contract. + * A single-file runtime uses the executable's `-rg` sidecar because a native + * helper cannot be spawned from pkg's virtual filesystem. Node-mode builds + * fall back to the platform package selected by `@vscode/ripgrep`. Resolving + * at the call boundary keeps a missing or corrupt binary at the first search + * call as `SEARCH_FAILED`, rather than failing the Loader composition. * * @returns the packaged binary's absolute path; the memoized promise rejects * when the platform package cannot be resolved. */ export function resolveRgPath(): Promise { - rgPathPromise ??= import('@vscode/ripgrep').then(module => module.rgPath) + rgPathPromise ??= Promise.resolve().then(async () => { + const executableSidecar = `${process.execPath}-rg` + if (existsSync(executableSidecar)) return executableSidecar + return (await import('@vscode/ripgrep')).rgPath + }) return rgPathPromise } diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 16d127bbe2..b13911cb25 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -15,7 +16,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' -const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' +const SHELL_PROMPT = CONTROLLED_PROMPT const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' // One page is enough to find a just-emitted completion marker; the full // scrollback is assembled only when a command settles or needs partial output. diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 55e89bc9f4..f04981f2cd 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -84,8 +84,8 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { ' config:', ' pollIntervalMs: 10', ' exactProbeAfterMs: 20', - ' idleSilenceMs: 100', - ' handoffGraceMs: 100', + ' idleSilenceMs: 3000', + ' handoffGraceMs: 500', ' scrollbackLines: 20000', ' timeoutMs: 2000', ' disposeGraceMs: 500', @@ -131,7 +131,9 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { }) expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash']) + const startedAt = Date.now() await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested') + expect(Date.now() - startedAt).toBeLessThan(2_000) const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"')) expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) expect(observed).not.toContain('DSH_PERSISTENT_BASH') diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index b3de46643c..88a9ffa3f4 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import TerminalSessionService from '@deepseek-ai/dsh-terminal' +import TerminalSessionService, { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSession, @@ -100,7 +100,7 @@ type StubMode = | 'paged-scrollback' class StubPtySession implements TerminalBackendSession { - readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' + readonly motd = CONTROLLED_PROMPT readonly pid = 123 statusValue: TerminalSessionStatus = { kind: 'running' } scrollback = this.motd diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index f31de010de..06a44665cf 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -13,9 +13,9 @@ export interface ProcessIdentity { /** Injectable OS process operations used by one local PTY session. */ export interface ProcessInspector { foregroundPgid(shellPid: number): number | undefined - isStdinWaiting(pgid: number): boolean + isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean /** Return the root and its current transitive descendants, children first. */ - processTree(rootPid: number): ProcessIdentity[] + processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[] /** Return current members of one POSIX process session when the platform exposes them. */ processSession(sessionId: number): ProcessIdentity[] /** Return whether the exact identity remains a non-quiescent process. */ @@ -226,12 +226,26 @@ function syscallWaitsOnStdin( return false } +function processWaitsOnStdin( + internals: ProcessInspectorInternals, + pid: number, + processGroupId: number, + table: SyscallTable, +): boolean { + if (readLinuxStat(internals, pid)?.pgrp !== processGroupId) return false + for (const tid of numericEntries(internals, `/proc/${pid}/task`)) { + const syscall = readSyscall(internals, pid, tid) + if (syscall !== undefined && syscallWaitsOnStdin(internals, pid, syscall, table)) return true + } + return false +} + abstract class PosixProcessInspector implements ProcessInspector { constructor(protected readonly internals: ProcessInspectorInternals) {} abstract foregroundPgid(shellPid: number): number | undefined - abstract isStdinWaiting(pgid: number): boolean - abstract processTree(rootPid: number): ProcessIdentity[] + abstract isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean + abstract processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[] abstract processSession(sessionId: number): ProcessIdentity[] abstract isAlive(identity: ProcessIdentity): boolean @@ -270,6 +284,34 @@ function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdent return result } +function linuxProcessTreeFromChildren( + internals: ProcessInspectorInternals, + rootPid: number, +): ProcessIdentity[] | undefined { + const root = readLinuxStat(internals, rootPid) + if (root === undefined) return [] + const visited = new Set() + const result: ProcessIdentity[] = [] + const visit = (entry: ProcStat): boolean => { + if (visited.has(entry.pid)) return true + visited.add(entry.pid) + let children: string + try { + children = internals.readFile(`/proc/${entry.pid}/task/${entry.pid}/children`) + } catch (_unreadableChildren) { + return false + } + for (const token of children.trim().split(/\s+/)) { + if (token.length === 0 || !/^\d+$/.test(token)) continue + const child = readLinuxStat(internals, Number(token)) + if (child !== undefined && !visit(child)) return false + } + result.push({ pid: entry.pid, started: entry.started }) + return true + } + return visit(root) ? result : undefined +} + class LinuxProcessInspector extends PosixProcessInspector { constructor( private readonly arch: NodeJS.Architecture, @@ -283,20 +325,32 @@ class LinuxProcessInspector extends PosixProcessInspector { return tpgid !== undefined && tpgid > 0 ? tpgid : undefined } - isStdinWaiting(pgid: number): boolean { + isStdinWaiting(pgid: number, scanNamespace = true): boolean { const table = SYSCALLS[this.arch] if (table === undefined) return false + // A POSIX process group is normally led by PID == PGID. Interactive shells + // wait on stdin in that leader, so inspect it before walking the whole PID + // namespace. Large container PID namespaces otherwise make every PTY + // readiness poll scan thousands of unrelated processes. + if (processWaitsOnStdin(this.internals, pgid, pgid, table)) return true + if (!scanNamespace) return false for (const pid of numericEntries(this.internals, '/proc')) { - if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue - for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) { - const syscall = readSyscall(this.internals, pid, tid) - if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true - } + if (pid === pgid) continue + if (processWaitsOnStdin(this.internals, pid, pgid, table)) return true } return false } - processTree(rootPid: number): ProcessIdentity[] { + processTree(rootPid: number, scanNamespace = true): ProcessIdentity[] { + // Linux exposes each process's direct children without requiring a scan of + // the container's whole PID namespace. Fall back for kernels or procfs + // mounts that do not provide the children file. + const rooted = linuxProcessTreeFromChildren(this.internals, rootPid) + if (rooted !== undefined) return rooted + if (!scanNamespace) { + const root = readLinuxStat(this.internals, rootPid) + return root === undefined ? [] : [{ pid: root.pid, started: root.started }] + } const entries = numericEntries(this.internals, '/proc').flatMap((pid) => { const stat = readLinuxStat(this.internals, pid) return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }] @@ -338,11 +392,11 @@ class MacProcessInspector extends PosixProcessInspector { } } - isStdinWaiting(_pgid: number): boolean { + isStdinWaiting(_pgid: number, _scanNamespace = true): boolean { return false } - processTree(rootPid: number): ProcessIdentity[] { + processTree(rootPid: number, _scanNamespace = true): ProcessIdentity[] { return processTree(macProcessTable(this.internals), rootPid) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 6d818c8a7f..bfba98dfb9 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -57,7 +57,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private readonly graceMs: number, ) { this.pid = terminal.pid - this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) + this.rootIdentity = inspector.processTree(this.pid, false).find(member => member.pid === this.pid) this.done = this.outcome.promise this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) }) this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => { @@ -81,12 +81,14 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { // Local inspection is synchronous; the seam returns a promise for remote transports. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async inspectForeground(): Promise { - this.descendants() + // Readiness polling may run every few milliseconds. Track the rooted tree + // here, but reserve the full process-session sweep for teardown. + this.descendants(false, false) const processGroupId = this.inspector.foregroundPgid(this.pid) if (processGroupId === undefined) return undefined return { processGroupId, - inputWaiting: this.inspector.isStdinWaiting(processGroupId), + inputWaiting: this.inspector.isStdinWaiting(processGroupId, false), } } @@ -141,20 +143,22 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { return members.filter(member => this.inspector.isAlive(member)) } - private descendants(): ProcessIdentity[] { + private descendants(includeSession = true, scanNamespace = true): ProcessIdentity[] { // Adopt newly scanned members only while the numeric root pid provably // still carries the spawned shell's start identity: after the shell dies, // a recycled pid's tree and session must not donate an unrelated // process's children to this session's signalling. Already-adopted // members keep their own start identities, which every signal rechecks. - const tree = this.inspector.processTree(this.pid) + const tree = this.inspector.processTree(this.pid, scanNamespace) const root = tree.find(member => member.pid === this.pid) const rootVerified = this.rootIdentity !== undefined && root !== undefined && root.started === this.rootIdentity.started this.trackedDescendants = this.survivors(this.unionMembers( this.trackedDescendants, - ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : [], + ...rootVerified + ? [tree, ...includeSession ? [this.inspector.processSession(this.pid)] : []] + : [], ).filter(member => member.pid !== this.pid)) return this.trackedDescendants } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index c90a7b3490..84cae7564a 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -95,6 +95,9 @@ describe('Linux process inspector', () => { fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501')) fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10)) fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12)) + fake.files.set('/proc/10/task/10/children', '12') + fake.files.set('/proc/12/task/12/children', '13') + fake.files.set('/proc/13/task/13/children', '') const inspector = createProcessInspector('linux', 'x64', fake.internals) expect(inspector.foregroundPgid(10)).toBe(40) expect(inspector.foregroundPgid(11)).toBeUndefined() @@ -124,6 +127,28 @@ describe('Linux process inspector', () => { expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']]) }) + it('walks a rooted process tree without enumerating the PID namespace', () => { + const fake = fakeInternals() + fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) + fake.files.set('/proc/10/task/10/children', '11') + fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) + fake.files.set('/proc/11/task/11/children', '') + + expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ + { pid: 11, started: '501' }, + { pid: 10, started: '500' }, + ]) + }) + + it('keeps readiness inspection local when procfs has no children index', () => { + const fake = fakeInternals() + fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) + const inspector = createProcessInspector('linux', 'x64', fake.internals) + + expect(inspector.processTree(10, false)).toEqual([{ pid: 10, started: '500' }]) + expect(inspector.isStdinWaiting(10, false)).toBe(false) + }) + it('detects read, select, poll, and epoll waits across non-leader threads', () => { const fake = fakeInternals() fake.dirs.set('/proc', ['100', '101']) @@ -156,6 +181,16 @@ describe('Linux process inspector', () => { expect(inspector.isStdinWaiting(77)).toBe(true) }) + it('checks a waiting process-group leader without scanning the PID namespace', () => { + const fake = fakeInternals() + fake.files.set('/proc/77/stat', stat(77, 77, 77, 77, '1')) + fake.dirs.set('/proc/77/task', ['77']) + fake.files.set('/proc/77/task/77/syscall', syscall(0, 0)) + + const inspector = createProcessInspector('linux', 'x64', fake.internals) + expect(inspector.isStdinWaiting(77)).toBe(true) + }) + it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => { const fake = fakeInternals() fake.dirs.set('/proc', ['100']) diff --git a/packages/terminal/terminal-bash/src/sanitize.ts b/packages/terminal/terminal-bash/src/sanitize.ts index 1f28315fb1..73f9d74e7b 100644 --- a/packages/terminal/terminal-bash/src/sanitize.ts +++ b/packages/terminal/terminal-bash/src/sanitize.ts @@ -2,12 +2,11 @@ import { Buffer } from 'node:buffer' +export { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' + /** OSC marker emitted by the controlled bash before each prompt. */ export const PROMPT_MARKER_PREFIX = '133;D;' -/** Exact printable prompt emitted after the private marker. */ -export const CONTROLLED_PROMPT = 'dsh> ' - /** One sanitized chunk plus whether it contained the owned prompt marker. */ export interface SanitizedChunk { text: string diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index d9e5444465..6f782e3bab 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -42,6 +42,9 @@ export type { } from './types.ts' export { TerminalBackendCleanupError } from './types.ts' +/** Printable prompt shared by terminal backends and persistent shell consumers. */ +export const CONTROLLED_PROMPT = 'dsh> ' + /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */ export type TerminalSessionId = TerminalSessionIdValue diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1490a0f9f7..2962e3c89d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8340,6 +8340,9 @@ importers: '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../../packages/examples/agent-spine-demo + '@deepseek-ai/dsh-agent-tool-presentation': + specifier: workspace:^ + version: link:../../packages/core/agent-tool-presentation '@deepseek-ai/dsh-anonymous-user-id': specifier: workspace:^ version: link:../../packages/identity/anonymous-user-id @@ -8361,6 +8364,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker-thread': specifier: workspace:^ version: link:../../packages/code-runtime/code-runtime-worker-thread + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../packages/compaction/command-compact '@deepseek-ai/dsh-command-goal': specifier: workspace:^ version: link:../../packages/goal/command-goal @@ -8442,6 +8448,9 @@ importers: '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../packages/interaction/permission-presets + '@deepseek-ai/dsh-persona': + specifier: workspace:^ + version: link:../../packages/preset/persona '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode @@ -8514,6 +8523,9 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem + '@deepseek-ai/dsh-spill': + specifier: workspace:^ + version: link:../../packages/spill/spill '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent @@ -8568,12 +8580,18 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs-search '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../packages/jobs/tool-jobs + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../packages/workflow/tool-ralph '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 400d9d585b..ef5a621f9d 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -67,7 +67,7 @@ class RuntimeBuildHook(BuildHookInterface): expected_executable = matches[0][1] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) - expected_files = [expected_executable] + expected_files = [expected_executable, f"{expected_executable}-rg"] if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") found_files = [path.name for path in runtime_files] diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 89d4b81b22..083a8a87d7 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", @@ -22,6 +23,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", + "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", @@ -49,6 +51,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", @@ -71,6 +74,7 @@ "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", + "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", @@ -89,7 +93,9 @@ "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 16af912def..727ee29586 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -5,8 +5,8 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's - **exe (production)**: single-file Node executables named ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in - {x64, arm64}); macOS also uses a sibling ``-spawn-helper``. The target machine - needs no Node installation. + {x64, arm64}) with a sibling ``-rg`` executable; macOS also uses a sibling + ``-spawn-helper``. The target machine needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`` on a @@ -83,6 +83,12 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) + ripgrep = Path(f"{path}-rg") + if not ripgrep.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the ripgrep sidecar at {ripgrep}. " + + _EXE_ACQUISITION_HINT + ) if tag.startswith("macos-"): helper = Path(f"{path}-spawn-helper") if not helper.is_file(): diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7ff9b15d59..b21a485949 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -92,6 +92,10 @@ def test_stage_runtime_copies_platform_payload( executable.write_bytes(b"runtime") executable.chmod(0o755) expected = {executable.name: b"runtime"} + ripgrep = Path(f"{executable}-rg") + ripgrep.write_bytes(b"ripgrep") + ripgrep.chmod(0o755) + expected[ripgrep.name] = b"ripgrep" if with_helper: spawn_helper = Path(f"{executable}-spawn-helper") spawn_helper.write_bytes(b"helper") diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index da1cea67c4..c104d47ef5 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -396,7 +396,8 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } - if (target.platform !== 'macos') return [product] + const ripgrep = await this.copyRipgrepSidecar(target, product) + if (target.platform !== 'macos') return [product, ripgrep] const spawnHelper = `${product}-spawn-helper` const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') if (this.cli.dryRun) { @@ -405,7 +406,31 @@ class SingleExeBuild { await copyFile(source, spawnHelper) await chmod(spawnHelper, 0o755) } - return [product, spawnHelper] + return [product, ripgrep, spawnHelper] + } + + /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */ + private async copyRipgrepSidecar(target: Target, product: string): Promise { + const platform = target.platform === 'macos' ? 'darwin' : target.platform + const source = join( + this.staging, + 'node_modules', + '@vscode', + `ripgrep-${platform}-${target.arch}`, + 'bin', + 'rg', + ) + const destination = `${product}-rg` + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) + return destination + } + if (!existsSync(source)) { + throw new Error(`build-exe-for-python-sdk: target ripgrep binary is missing at ${source}.`) + } + await copyFile(source, destination) + await chmod(destination, 0o755) + return destination } /** diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 18660b3411..0b4157f40d 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -48,7 +48,8 @@ PLATFORMS = load_platforms() def runtime_suffixes(executable_name: str) -> tuple[str, ...]: - return ("", "-spawn-helper") if "-macos-" in executable_name else ("",) + suffixes = ("", "-rg") + return (*suffixes, "-spawn-helper") if "-macos-" in executable_name else suffixes def main() -> None: diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 700cfb7c79..8eb079fd78 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -29,6 +29,9 @@ MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and stri MINIMAL_TEXT = "minimal agent smoke ok" MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." +FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools." +FS_SEARCH_TEXT = "filesystem search smoke ok" +FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK" MINIMAL_CORDIS = ( Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" ) @@ -109,6 +112,29 @@ CUSTOM_CORDIS = """\ - id: cordis-tool name: '@deepseek-ai/dsh-tool-cordis' """ +FS_SEARCH_CORDIS = """\ +- id: sdk-jsonrpc-server + name: '@deepseek-ai/dsh-sdk-jsonrpc-server' +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT + compression: 'none' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' +- id: fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false +""" class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -143,6 +169,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) + fs_search = fs_search_tool_followup(call_id, tool_name, tool_text) + if fs_search is not None: + return fs_search minimal = minimal_tool_followup(body, call_id, tool_name, tool_text) if minimal is not None: return minimal @@ -192,6 +221,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: SNAPSHOT_PROMPT, CODE_PROMPT, WORKFLOW_PROMPT, + FS_SEARCH_PROMPT, } prompt = next( (candidate for candidate in user_prompts if candidate in scenario_prompts), @@ -233,9 +263,40 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: }, }, ) + if prompt == FS_SEARCH_PROMPT: + assert_advertised_tool(body, "grep") + assert_advertised_tool(body, "glob") + return tool_call_chunks( + "fs-search-grep", + "grep", + {"pattern": FS_SEARCH_MARKER, "path": "."}, + ) return text_chunks(EXPECTED_TEXT) +def fs_search_tool_followup( + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Exercise both ripgrep-backed tools through the packaged executable.""" + if not call_id.startswith("fs-search-"): + return None + if call_id == "fs-search-grep" and tool_name == "grep": + if "needle.txt" not in tool_text or FS_SEARCH_MARKER not in tool_text: + raise AssertionError(f"packaged grep returned no marker: {tool_text}") + return tool_call_chunks( + "fs-search-glob", + "glob", + {"pattern": "**/*.txt"}, + ) + if call_id == "fs-search-glob" and tool_name == "glob": + if "needle.txt" not in tool_text: + raise AssertionError(f"packaged glob returned no fixture path: {tool_text}") + return text_chunks(FS_SEARCH_TEXT) + raise AssertionError(f"unexpected filesystem-search follow-up: {call_id} {tool_name}: {tool_text}") + + def minimal_tool_followup( body: dict[str, object], call_id: str, @@ -477,13 +538,13 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None: + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"} and args.exe is None: parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-snapshot or all") @@ -499,6 +560,9 @@ def main() -> None: if args.scenario in {"all", "sdk-minimal"}: assert args.exe is not None smoke_sdk_minimal(model.url, args.exe.resolve()) + if args.scenario in {"all", "sdk-fs-search"}: + assert args.exe is not None + smoke_sdk_fs_search(model.url, args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -588,6 +652,33 @@ def smoke_sdk_minimal(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") +def smoke_sdk_fs_search(base_url: str, executable: Path) -> None: + """Exercise real grep and glob spawns through the packaged executable.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-fs-search-") as temporary: + root = Path(temporary).resolve() + (root / "needle.txt").write_text(f"{FS_SEARCH_MARKER}\n") + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(FS_SEARCH_CORDIS) + with DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + result = harness.run(FS_SEARCH_PROMPT, session_id="fs-search-smoke") + + assert result.final_response == FS_SEARCH_TEXT, result.final_response + assert_session_log(sessions, root, FS_SEARCH_TEXT, FS_SEARCH_MARKER, "needle.txt") + + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" from deepseek_harness import DeepSeekHarness diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index c87d562f59..c0015fe11a 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -1,12 +1,18 @@ /** - * Verify that the executable deploy manifest supplies every required workspace - * peer in its dependency graph. With auto peer installation disabled, a missing - * root peer can otherwise fail only when Cordis loads the packaged plugin. + * Verify that the executable deploy manifest supplies every plugin referenced + * by a shipped agent preset and every required workspace peer in its dependency + * graph. With auto peer installation disabled, either omission can otherwise + * fail only when Cordis loads the packaged plugin. */ import { globSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import { resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import { parseArgs } from 'node:util' +import * as yaml from 'js-yaml' + +interface JsExpr { + __jsExpr: string +} interface PackageManifest { name?: string @@ -21,6 +27,23 @@ interface WorkspacePackage { manifest: PackageManifest } +interface RuntimePlatform { + tag: string + executable: string +} + +type RuntimePlatformManifest = Record + +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + resolve: data => typeof data === 'string', + construct: (data: unknown): JsExpr => { + if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string') + return { __jsExpr: data } + }, +}) +const schema = yaml.JSON_SCHEMA.extend(jsExprType) + const root = resolve(import.meta.dirname, '..') const { values } = parseArgs({ args: process.argv.slice(2), @@ -31,6 +54,7 @@ const runtimeManifest = await loadManifest(runtimeManifestPath) const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime' const workspace = await loadWorkspacePackages() const runtimeDependencies = runtimeManifest.dependencies ?? {} +const platforms = await loadJson(resolve(root, 'python/sdk-runtime/platforms.json')) const parents = new Map() const queue: string[] = [] @@ -40,7 +64,7 @@ for (const dependency of Object.keys(runtimeDependencies).sort()) { queue.push(dependency) } -const failures: string[] = [] +const failures = await missingPresetPlugins(runtimeDependencies, platforms) for (let index = 0; index < queue.length; index += 1) { const packageName = queue[index] if (packageName === undefined) continue @@ -65,12 +89,91 @@ for (let index = 0; index < queue.length; index += 1) { } if (failures.length > 0) { - console.error('verify-runtime-closure: required workspace peers are missing from python/sdk-runtime dependencies:') + console.error('verify-runtime-closure: preset plugins or required workspace peers are missing from python/sdk-runtime dependencies:') for (const failure of failures) console.error(` ${failure}`) process.exit(1) } -console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`) +const presetCount = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length +console.log( + `verify-runtime-closure: ${presetCount} agent presets and ${queue.length} workspace packages form a closed runtime dependency graph.`, +) + +async function missingPresetPlugins( + runtimeDependencies: Readonly>, + platforms: RuntimePlatformManifest, +): Promise { + const missing = new Map>() + const failures: string[] = [] + const presetPaths = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).sort() + for (const presetPath of presetPaths) { + const document: unknown = yaml.load(await readFile(resolve(root, presetPath), 'utf8'), { schema }) + if (!Array.isArray(document)) { + failures.push(`${presetPath}: preset root must be a Loader entry array`) + continue + } + for (const target of Object.keys(platforms).sort()) { + const processPlatform = processPlatformForTarget(target) + for (const plugin of activeBarePluginPackages(document, processPlatform)) { + if (runtimeDependencies[plugin] !== undefined) continue + const preset = basename(dirname(presetPath)) + const key = `${preset} preset -> ${plugin}` + const targets = missing.get(key) ?? new Set() + targets.add(target) + missing.set(key, targets) + } + } + } + failures.push(...[...missing.entries()].map(([chain, targets]) => + `${chain} (${[...targets].sort().join(', ')})`)) + return failures +} + +function activeBarePluginPackages(entries: unknown[], processPlatform: string): Set { + const packages = new Set() + const visit = (value: unknown, parentDisabled: boolean): void => { + if (!isRecord(value)) return + const disabled = parentDisabled || disabledOnPlatform(value.disabled, processPlatform) + if (disabled) return + if (typeof value.name === 'string') { + const packageName = barePackageName(value.name) + if (packageName !== undefined) packages.add(packageName) + } + if (Array.isArray(value.config)) { + for (const child of value.config) visit(child, disabled) + } + } + for (const entry of entries) visit(entry, false) + return packages +} + +function disabledOnPlatform(value: unknown, processPlatform: string): boolean { + if (typeof value === 'boolean') return value + if (!isRecord(value) || typeof value.__jsExpr !== 'string') return false + const match = /^process\.platform\s*(===|!==)\s*(['"])(win32|linux|darwin)\2$/.exec(value.__jsExpr.trim()) + if (match === null) return false + const [, operator, , expected] = match + return operator === '===' ? processPlatform === expected : processPlatform !== expected +} + +function processPlatformForTarget(target: string): string { + if (target.startsWith('linux-')) return 'linux' + if (target.startsWith('macos-')) return 'darwin' + throw new Error(`verify-runtime-closure: unsupported runtime target ${JSON.stringify(target)}`) +} + +function barePackageName(specifier: string): string | undefined { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) return undefined + const parts = specifier.split('/') + if (specifier.startsWith('@')) { + return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined + } + return parts[0] || undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} async function loadWorkspacePackages(): Promise> { const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }) @@ -85,7 +188,11 @@ async function loadWorkspacePackages(): Promise> { } async function loadManifest(path: string): Promise { - return JSON.parse(await readFile(path, 'utf8')) as PackageManifest + return loadJson(path) +} + +async function loadJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as T } function formatChain( From acd8dd43fac57b8faf814d4400cd4ebff6c40735 Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 14 Aug 2026 13:59:49 +0800 Subject: [PATCH 02/56] fix(python-sdk): satisfy runtime packaging gates --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/terminal.i18n.yaml | 4 +-- docs/subsystems/terminal.md | 2 +- docs/subsystems/terminal.zh.md | 2 +- .../tool-fs-search/tests/rg-sidecar.spec.ts | 25 +++++++++++++ .../tests/process-inspector.spec.ts | 36 ++++++++++++++++++- python/sdk/tests/test_runtime_resolution.py | 18 +++++++++- scripts/cordis-yaml.ts | 26 ++++++++++++++ scripts/verify-cordis-config.ts | 25 ++----------- scripts/verify-runtime-closure.ts | 18 ++-------- 11 files changed, 114 insertions(+), 46 deletions(-) create mode 100644 packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts create mode 100644 scripts/cordis-yaml.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ef4931f765..e5b36bf91c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 82f6d26c79d32c6952f3bc11c96fa1c2ddceecdc +config-catalog.md: 2e4aad7532b061e8328f25a53c2c3b0c4bb4dfa0 config-catalog.zh.md: 958d3115447db37de248bbf30b0744308ff8dbb8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 82f6d26c79..2e4aad7532 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2370,7 +2370,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:406`](../packages/shell/tool-bash-persistent/src/index.ts) diff --git a/docs/subsystems/terminal.i18n.yaml b/docs/subsystems/terminal.i18n.yaml index 859104d415..96f05cc50a 100644 --- a/docs/subsystems/terminal.i18n.yaml +++ b/docs/subsystems/terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/terminal.md -terminal.md: eedf6157b256e83d3b57e07bf738429773b87574 -terminal.zh.md: 3cc2c281e8c2090bb7e92808f932be689fd0b710 +terminal.md: c7031143117a514f8579a22fed07a1461babb15e +terminal.zh.md: 7c469c028e8b3da7c9d5012f9f49c1ae65a9bd03 diff --git a/docs/subsystems/terminal.md b/docs/subsystems/terminal.md index eedf6157b2..c703114311 100644 --- a/docs/subsystems/terminal.md +++ b/docs/subsystems/terminal.md @@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts) +Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts) diff --git a/docs/subsystems/terminal.zh.md b/docs/subsystems/terminal.zh.md index 3cc2c281e8..7c469c028e 100644 --- a/docs/subsystems/terminal.zh.md +++ b/docs/subsystems/terminal.zh.md @@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts) +Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts) diff --git a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts new file mode 100644 index 0000000000..49cca6bfa8 --- /dev/null +++ b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' + +const existsSync = vi.hoisted(() => vi.fn(() => true)) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, existsSync } +}) + +vi.mock('@vscode/ripgrep', () => new Proxy({}, { + get() { + throw new Error('the platform package must not load when the executable sidecar exists') + }, +})) + +import { resolveRgPath } from '@deepseek-ai/dsh-tool-fs-search' + +describe('single-executable ripgrep resolution', () => { + it('uses the native sidecar beside the current executable', async () => { + const sidecar = `${process.execPath}-rg` + + await expect(resolveRgPath()).resolves.toBe(sidecar) + expect(existsSync).toHaveBeenCalledWith(sidecar) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index 84cae7564a..2402681461 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -140,6 +140,32 @@ describe('Linux process inspector', () => { ]) }) + it('contains cycles in the procfs children index', () => { + const fake = fakeInternals() + fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) + fake.files.set('/proc/10/task/10/children', '11') + fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) + fake.files.set('/proc/11/task/11/children', '10') + + expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ + { pid: 11, started: '501' }, + { pid: 10, started: '500' }, + ]) + }) + + it('falls back to the PID namespace when a descendant children index is unreadable', () => { + const fake = fakeInternals() + fake.dirs.set('/proc', ['10', '11', '12']) + fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) + fake.files.set('/proc/10/task/10/children', '12 invalid 11') + fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) + + expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ + { pid: 11, started: '501' }, + { pid: 10, started: '500' }, + ]) + }) + it('keeps readiness inspection local when procfs has no children index', () => { const fake = fakeInternals() fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) @@ -147,11 +173,19 @@ describe('Linux process inspector', () => { expect(inspector.processTree(10, false)).toEqual([{ pid: 10, started: '500' }]) expect(inspector.isStdinWaiting(10, false)).toBe(false) + + const readFile = fake.internals.readFile.bind(fake.internals) + let statReads = 0 + fake.internals.readFile = (path) => { + if (path === '/proc/10/stat' && statReads++ > 0) throw new Error('process exited') + return readFile(path) + } + expect(inspector.processTree(10, false)).toEqual([]) }) it('detects read, select, poll, and epoll waits across non-leader threads', () => { const fake = fakeInternals() - fake.dirs.set('/proc', ['100', '101']) + fake.dirs.set('/proc', ['77', '100', '101']) fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2')) fake.dirs.set('/proc/100/task', ['100']) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 778203f4d1..14e90f3283 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -51,7 +51,10 @@ def test_runtime_requires_spawn_helper_only_on_macos( runtime_dir.mkdir() linux = runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64" linux.touch() - (runtime_dir / "dsh-jsonrpc-agent-pkg-macos-arm64").touch() + Path(f"{linux}-rg").touch() + macos = runtime_dir / "dsh-jsonrpc-agent-pkg-macos-arm64" + macos.touch() + Path(f"{macos}-rg").touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "macos-arm64") @@ -59,3 +62,16 @@ def test_runtime_requires_spawn_helper_only_on_macos( runtime.bundled_runtime_path() monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") assert runtime.bundled_runtime_path() == linux + + +def test_runtime_requires_ripgrep_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + (runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64").touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") + + with pytest.raises(FileNotFoundError, match="ripgrep sidecar"): + runtime.bundled_runtime_path() diff --git a/scripts/cordis-yaml.ts b/scripts/cordis-yaml.ts new file mode 100644 index 0000000000..60cd492787 --- /dev/null +++ b/scripts/cordis-yaml.ts @@ -0,0 +1,26 @@ +import * as yaml from 'js-yaml' + +export interface JsExpr { + __jsExpr: string +} + +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + resolve: data => typeof data === 'string', + construct: (data: unknown): JsExpr => { + if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string') + return { __jsExpr: data } + }, +}) +const schema = yaml.JSON_SCHEMA.extend(jsExprType) + +/** Parse a Cordis config while preserving Loader `!!js` expressions as data. */ +export function loadCordisYaml(source: string): unknown { + return yaml.load(source, { schema }) +} + +export function isJsExpr(value: unknown): value is JsExpr { + return typeof value === 'object' + && value !== null + && typeof (value as Record).__jsExpr === 'string' +} diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index f2e0aef46c..9a94a281e8 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -12,13 +12,9 @@ import { globSync, readFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' -import * as yaml from 'js-yaml' import ts from 'typescript' import { cordisConfigFiles } from './cordis-config-files.ts' - -interface JsExpr { - __jsExpr: string -} +import { isJsExpr, loadCordisYaml } from './cordis-yaml.ts' interface PackageManifest { name?: string @@ -56,16 +52,6 @@ const CHOOSER_BACKEND_PACKAGES = [ '@deepseek-ai/dsh-client-ui-directory-picker-browse', '@deepseek-ai/dsh-client-ui-directory-picker-native', ] -const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { - kind: 'scalar', - resolve: data => typeof data === 'string', - construct: (data: unknown): JsExpr => { - if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string') - return { __jsExpr: data } - }, -}) -const schema = yaml.JSON_SCHEMA.extend(jsExprType) - const errors: string[] = [] const pluginReferences: PluginReference[] = [] @@ -73,7 +59,7 @@ if (import.meta.main) { const files = cordisConfigFiles(root) for (const file of files) { - const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) + const document = loadCordisYaml(readFileSync(resolve(root, file), 'utf8')) if (!isUnknownArray(document)) { errors.push(`${file}: root must be a Loader entry array`) continue @@ -172,7 +158,7 @@ function validatePresetPlaneSeparation(): string[] { /** Every entry of one config file, or an empty list when it is not an entry array. */ function loadEntries(file: string): unknown[] { - const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) + const document = loadCordisYaml(readFileSync(resolve(root, file), 'utf8')) return isUnknownArray(document) ? document : [] } @@ -462,7 +448,6 @@ export function metadataExpressionErrors(entry: Record, path: s function disabledExpressionProblem(expression: string): string | undefined { try { // Compilation only — the constructor never executes the body. - // oxlint-disable-next-line typescript/no-implied-eval new Function(`return (${expression})`) return undefined } catch (error) { @@ -484,10 +469,6 @@ function collectExpressionPaths(value: unknown, path: string, output: string[]): for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output) } -function isJsExpr(value: unknown): value is JsExpr { - return isRecord(value) && typeof value.__jsExpr === 'string' -} - function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' } diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index c0015fe11a..1287542dc2 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -8,11 +8,7 @@ import { globSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import { parseArgs } from 'node:util' -import * as yaml from 'js-yaml' - -interface JsExpr { - __jsExpr: string -} +import { loadCordisYaml } from './cordis-yaml.ts' interface PackageManifest { name?: string @@ -34,16 +30,6 @@ interface RuntimePlatform { type RuntimePlatformManifest = Record -const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { - kind: 'scalar', - resolve: data => typeof data === 'string', - construct: (data: unknown): JsExpr => { - if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string') - return { __jsExpr: data } - }, -}) -const schema = yaml.JSON_SCHEMA.extend(jsExprType) - const root = resolve(import.meta.dirname, '..') const { values } = parseArgs({ args: process.argv.slice(2), @@ -107,7 +93,7 @@ async function missingPresetPlugins( const failures: string[] = [] const presetPaths = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).sort() for (const presetPath of presetPaths) { - const document: unknown = yaml.load(await readFile(resolve(root, presetPath), 'utf8'), { schema }) + const document = loadCordisYaml(await readFile(resolve(root, presetPath), 'utf8')) if (!Array.isArray(document)) { failures.push(`${presetPath}: preset root must be a Loader entry array`) continue From a6d7ac7438acb2482b6915ce1dc79e2825e8856a Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 14 Aug 2026 14:12:23 +0800 Subject: [PATCH 03/56] fix(ci): avoid implied eval in config verification --- scripts/verify-cordis-config.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 9a94a281e8..9777979f5f 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -12,6 +12,7 @@ import { globSync, readFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' +import { Script } from 'node:vm' import ts from 'typescript' import { cordisConfigFiles } from './cordis-config-files.ts' import { isJsExpr, loadCordisYaml } from './cordis-yaml.ts' @@ -447,8 +448,8 @@ export function metadataExpressionErrors(entry: Record, path: s */ function disabledExpressionProblem(expression: string): string | undefined { try { - // Compilation only — the constructor never executes the body. - new Function(`return (${expression})`) + // Compilation only — constructing a Script does not execute its source. + new Script(`(${expression})`) return undefined } catch (error) { const detail = error instanceof Error ? error.message : String(error) From 8d9fee19f9ed1394c311012e0947d293114cab0b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 18:57:55 +0800 Subject: [PATCH 04/56] feat(commands): route composer image attachments through slash commands A claimed slash command consumed only the text half of the composer submission: /goal with reference images executed, cleared the draft, and silently stranded the images in the rail. Model-visible attachment intent had no route through the command plane. The submission envelope is now modeled end to end. CommandDefinition input.images declares acceptance; the declaration rides the descriptor to every client, onto the minted CommandClaim, and into the input machine's claim snapshot. commands.execute carries the submission's base64 images and enforces the declaration in the executor: non-declaring commands, a missing attachment store, and exceeded batch limits settle as logged error results before the handler runs. Admission reuses the attachment package's new admitEncodedImages, extracted from api-proxy's prompt path so both wire endpoints share one limits/validation/commit sequence. Producers own model visibility: /goal submits one user followup (image blocks + a fixed reference line) after a successful create/edit so goal rounds read the images from session history; /plan folds them into its steered message. Grammar misfits (/goal pause, bare /plan, /plan off) return direct errors and the composer keeps the images. On the client, enter adjudication carries a SubmitEnvelope and every command route that cannot consume images throws a localized refusal that renders as one composer notice with draft and images retained; the claimed pre-gate applies the same copy. An accepting claim serializes the draft images, forwards them to commands.execute, and clears plus releases them only on a success outcome. The assembled web test roster gains the ui-input-trigger and ui-commands plugins, mirroring the shipped composition, so slash submissions exercise the command plane; a new keyless snapshot pins the refusal banner and the accepting /goal flow over the built client graph. --- ...ommand-image-attachment-envelope.i18n.yaml | 6 + ...08-17-command-image-attachment-envelope.md | 42 ++++ ...17-command-image-attachment-envelope.zh.md | 42 ++++ apps/web/tests/assembled-boot.ts | 5 + .../tests/command-image-envelope.snapshot.ts | 78 ++++++++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 16 +- docs/subsystems/attachment.zh.md | 16 +- docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 33 +++- docs/subsystems/commands.zh.md | 33 +++- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 + packages/attachment/attachment/README.zh.md | 2 + packages/attachment/attachment/package.json | 3 +- .../attachment/attachment/src/admission.ts | 56 ++++++ packages/attachment/attachment/src/index.ts | 2 + packages/attachment/attachment/src/types.ts | 10 + .../attachment/tests/admission.spec.ts | 120 ++++++++++++ .../client/connection/src/client/fixture.ts | 20 +- .../tests/fixture-commands.client.spec.ts | 38 +++- .../runtime/src/client/sessions/session.ts | 2 +- packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 2 + packages/client/ui-commands/README.zh.md | 2 + .../client/ui-commands/src/client/locales.ts | 2 + .../client/ui-commands/src/client/service.ts | 49 ++++- .../ui-commands/tests/service.client.spec.ts | 135 ++++++++++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/input/contract.ts | 2 +- .../src/client/input/facade.ts | 39 +++- .../ui-conversation/src/client/input/hub.ts | 13 +- .../src/client/input/machine.ts | 10 +- .../ui-conversation/src/client/locales.ts | 2 + .../ui-conversation/src/client/service.ts | 26 ++- .../tests/input-bar.client.spec.tsx | 1 + .../tests/input-matrix.client.spec.tsx | 90 ++++++++- .../tests/input-scenarios.client.spec.tsx | 71 +++++-- .../tests/skeleton.client.spec.tsx | 2 +- .../client/ui-input-trigger/README.i18n.yaml | 4 +- packages/client/ui-input-trigger/README.md | 2 +- packages/client/ui-input-trigger/README.zh.md | 2 +- .../ui-input-trigger/src/client/controller.ts | 10 +- .../ui-input-trigger/src/client/index.ts | 4 +- packages/client/ui-input-trigger/src/types.ts | 46 ++++- .../tests/service.client.spec.ts | 26 ++- packages/client/ui-plan/src/client/index.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 2 +- .../tests/command-compact.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 14 +- .../tests/command-feedback.spec.ts | 7 +- .../tests/loader-composition.spec.ts | 4 +- packages/goal/command-goal/README.i18n.yaml | 4 +- packages/goal/command-goal/README.md | 6 +- packages/goal/command-goal/README.zh.md | 6 +- packages/goal/command-goal/src/index.ts | 44 ++++- .../command-goal/tests/command-goal.spec.ts | 93 ++++++++- packages/goal/command-goal/tsconfig.json | 3 + packages/host/apiproxy/src/api-proxy.ts | 50 +---- .../interaction/commands/README.i18n.yaml | 4 +- packages/interaction/commands/README.md | 6 +- packages/interaction/commands/README.zh.md | 6 +- packages/interaction/commands/package.json | 4 + packages/interaction/commands/src/index.ts | 92 +++++++-- packages/interaction/commands/src/types.ts | 8 + .../commands/tests/commands.spec.ts | 179 +++++++++++++++--- packages/interaction/commands/tsconfig.json | 6 + .../tests/projection.spec.ts | 6 +- packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 4 +- packages/plan/plan-mode/README.zh.md | 4 +- packages/plan/plan-mode/src/index.ts | 18 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 73 +++++-- .../tests/loader-composition.client.spec.ts | 2 +- pnpm-lock.yaml | 6 + scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 + tsconfig.base.json | 1 + 87 files changed, 1499 insertions(+), 279 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md create mode 100644 .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md create mode 100644 apps/web/tests/command-image-envelope.snapshot.ts create mode 100644 packages/attachment/attachment/src/admission.ts create mode 100644 packages/attachment/attachment/tests/admission.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml new file mode 100644 index 0000000000..d7fa3d5b9d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md +2026-08-17-command-image-attachment-envelope.md: 89a8d8a047005d8267e3cb5e368d9ed938865494 +2026-08-17-command-image-attachment-envelope.zh.md: 27fe48fcaa80ea47fc1598f3242deaf00bff83a6 diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md new file mode 100644 index 0000000000..89a8d8a047 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md @@ -0,0 +1,42 @@ +# Agent Note: Command image-attachment envelope + +Status: implemented + +English | [中文](2026-08-17-command-image-attachment-envelope.zh.md) + +## Problem + +The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode `defaultSink → conversation.sendSession`, which serialized the images into prompt content and cleared them on success. A claimed slash command rode `claim.submit(args, actx)`, a text-only transaction: `/goal rebuild the cathedral` with four reference photos executed the command, cleared the draft, and silently stranded the images in the composer rail. The model never saw them, and no surface said so. The defect was contract-level, not a missed call site: nothing in the claim, the adjudication, or the host executor modeled attachments, so any command could consume the text half of a submission and drop the rest. + +Merging the two planes was not on the table — the [plugin command registration Agent Note](2026-07-19-plugin-command-registration.md) deliberately keeps human commands out of the model plane, and that separation is correct. The gap was that the envelope fractured at the plane fork. + +## Decision + +The submission envelope is modeled end to end, and every command route either consumes it whole or refuses it loudly. + +**Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot. + +**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission reuses the attachment package's `admitEncodedImages`, extracted from api-proxy's prompt path so both wire endpoints share one limits/validation/commit sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`. + +**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan` folds the images into the message it already steers. Both producers reject sub-commands whose grammar has no carrier (`/goal pause`, bare `/plan`, `/plan off`) with a direct error, which keeps the composer's images in place. + +**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine renders the rejection as one composer notice with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them. + +## Testing + +Registry executor enforcement, admission failure settlement, and frozen invocation attachments are covered in `packages/interaction/commands/tests/commands.spec.ts`; batch admission ordering and limits in `packages/attachment/attachment/tests/admission.spec.ts`; producer behavior in `packages/goal/command-goal/tests/command-goal.spec.ts` and `packages/plan/plan-mode/tests/plan-mode.spec.ts`; client refusal and consumption paths in the ui-commands, ui-conversation, and ui-input-trigger client suites; and the assembled-application flow in the apps/web keyless lanes. + +## Alternatives considered + +- **Block commands whenever images are attached (no acceptance path)** — rejected: predictable, but `/goal` with reference images is the motivating use case; the user's images would have no route to the model at all. +- **Auto-send stranded images as a follow-up user message after any command** — rejected: surprising for host-state commands (`/model`, `/compact`), and it moves the message contract from the producer to the composer, against the command registry's "producer owns model-visible work" rule. +- **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility. +- **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors. +- **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself. + +## Consequences + +- No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike. +- The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly. +- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), billed like any image prompt. +- Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary. diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md new file mode 100644 index 0000000000..27fe48fcaa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md @@ -0,0 +1,42 @@ +# Agent Note: Command image-attachment envelope + +Status: implemented + +[English](2026-08-17-command-image-attachment-envelope.md) | 中文 + +## Problem + +Web composer 的一次提交是一个信封——草稿文本、已附加图片、投递模式——但两条提交平面对它的消费是不对称的。普通消息走 `defaultSink → conversation.sendSession`,图片被序列化进 prompt 内容并在成功后清除。被 claim 的斜杠命令走 `claim.submit(args, actx)`,一个纯文本事务:`/goal rebuild the cathedral` 带四张参考照片时,命令执行、草稿清空,图片却静默滞留在 composer 附件栏。模型从未看到它们,也没有任何界面提示。这个缺陷在契约层面而非某个漏掉的调用点:claim、裁决、宿主执行器都没有建模附件,因此任何命令都可能消费提交的文本一半而丢弃其余部分。 + +合并两个平面从未在考虑范围内——[插件命令注册 Agent Note](2026-07-19-plugin-command-registration.md)刻意让人类命令留在模型平面之外,这个分离是正确的。问题在于信封在平面分叉处被拆散了。 + +## Decision + +提交信封被端到端建模,每条命令路径要么整体消费它,要么响亮拒绝。 + +**声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。 + +**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入复用 attachment 包的 `admitEncodedImages`——从 api-proxy 的 prompt 路径提取而来,使两个 wire 端点共享同一套限额、校验与提交序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。 + +**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan` 把图片并入它本就要 steer 的消息。两个生产方都会拒绝语法上没有载体的子命令(`/goal pause`、不带参数的 `/plan`、`/plan off`),直接返回错误,composer 的图片原地保留。 + +**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机把拒绝渲染为一条 composer 通知,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。 + +## Testing + +注册表执行器强制、准入失败结算、冻结的调用附件由 `packages/interaction/commands/tests/commands.spec.ts` 覆盖;批量准入顺序与限额在 `packages/attachment/attachment/tests/admission.spec.ts`;生产方行为在 `packages/goal/command-goal/tests/command-goal.spec.ts` 与 `packages/plan/plan-mode/tests/plan-mode.spec.ts`;客户端拒绝与消费路径在 ui-commands、ui-conversation、ui-input-trigger 客户端套件;组装后应用流程在 apps/web 的 keyless 通道。 + +## Alternatives considered + +- **附加图片时一律拦截命令(没有接受路径)**——被拒绝:可预测,但带参考图的 `/goal` 正是驱动这次修复的用例,用户的图片将完全没有通往模型的路径。 +- **任何命令后把滞留图片自动作为后续用户消息发送**——被拒绝:对宿主状态命令(`/model`、`/compact`)令人意外,且把消息契约从生产方挪到 composer,违反命令注册表「生产方负责模型可见工作」的规则。 +- **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。 +- **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。 +- **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。 + +## Consequences + +- 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。 +- commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。 +- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),计费与任何图片提示词相同。 +- 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 52c0658e4e..392a0edeb0 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -43,6 +43,11 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ '@deepseek-ai/dsh-client-ui-sidebar', ], }, + // The '/' pipeline and its command surface, mirroring the shipped web-app + // composition so slash submissions exercise the command plane instead of + // silently falling to the default prompt sink. + { id: '@deepseek-ai/dsh-client-ui-input-trigger', bundlePath: 'packages/client/ui-input-trigger/lib/client.js', url: '/plugins/ui-input-trigger.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-commands', bundlePath: 'packages/client/ui-commands/lib/client.js', url: '/plugins/ui-commands.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-input-trigger', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-session-log-export', bundlePath: 'packages/session-query/session-log-export/lib/client.js', url: '/plugins/session-log-download.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-commands', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] diff --git a/apps/web/tests/command-image-envelope.snapshot.ts b/apps/web/tests/command-image-envelope.snapshot.ts new file mode 100644 index 0000000000..a4601a2541 --- /dev/null +++ b/apps/web/tests/command-image-envelope.snapshot.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +// The command image-attachment envelope over the BUILT client graph (real +// bundles via AppWebEntry, keyless FixtureApiClient transport): an enter +// submission carrying composer images resolves only through a command whose +// descriptor declares `input.images`. A non-declaring command refuses with +// one composer notice and everything retained; a declaring command consumes +// the images — serialized through the real draft-image chain into the +// commands/execute payload — and clears the composer on success. +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +/** Open a fresh fixture session and return its composer textarea. */ +async function freshComposer(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') + if (start === null) throw new Error('fixture Workspace new-session action missing') + fireEvent.click(start) + return await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) as HTMLTextAreaElement +} + +/** Paste one tiny PNG into the composer and wait for its rail thumbnail. */ +async function pasteImage(textarea: HTMLTextAreaElement, name: string): Promise { + const image = new File([new Uint8Array([137, 80, 78, 71])], name, { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + await waitFor(() => { + const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + if (rail === null) throw new Error('attachment rail missing') + expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name) + }, { timeout: 5_000 }) +} + +it('refuses an image-carrying submit to a non-declaring command and keeps draft and images', async () => { + mountAssembledApp() + const textarea = await freshComposer() + await pasteImage(textarea, 'ref.png') + + // /echo is a leadingInput fixture command without `input.images`. + fireEvent.change(textarea, { target: { value: '/echo hello' } }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + // Several live-region elements exist (session activity among them); the + // refusal is the status whose text is the localized notice. + const notice = await waitFor(() => { + const el = [...document.querySelectorAll('[role="status"]')] + .find(candidate => candidate.textContent?.includes('image attachments') ?? false) + if (el === undefined) throw new Error('composer refusal notice missing') + return el + }, { timeout: 5_000 }) + expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first') + // The whole envelope is retained: draft text and the rail thumbnail. + expect(textarea.value).toBe('/echo hello') + const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png']) +}) + +it('consumes images through a declaring command and clears the composer on success', async () => { + mountAssembledApp() + const textarea = await freshComposer() + await pasteImage(textarea, 'goal-ref.png') + + // /goal declares `input.images` in the fixture catalog; the claim submit + // serializes the pasted bytes and the fixture executor admits them. + fireEvent.change(textarea, { target: { value: '/goal rebuild the cathedral' } }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + await waitFor(() => { + expect(textarea.value).toBe('') + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + }, { timeout: 5_000 }) +}) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 7e67006a40..ea284f3559 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 6a79e6f7ce5addc64b10efa8da7a886dcfb36dc2 -event-producer-consumer.zh.md: f7576a8e28e4f1db2c65c324595c05c98b8fe488 +event-producer-consumer.md: dbad93fae91928c8ded703784446573fbaca0a32 +event-producer-consumer.zh.md: deac2275fae9e0d87f4b47092d71050c36f19785 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6a79e6f7ce..dbad93fae9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f7576a8e28..deac2275fa 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,7 +24,7 @@ | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index bc6e3dde66..bd75b9516c 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: c400298f7d37c590918820bcbda10e6550f197e8 -persistence-catalog.zh.md: 65ec0e3fbdd226c51a371dc9a90f10db5c929c7a +persistence-catalog.md: c78c6c9b7c116b5ea545a6ecb6e0f5c9013a53a7 +persistence-catalog.zh.md: b787c8c30e0d695246db15372b639bd5bde44c07 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c400298f7d..c78c6c9b7c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -256,7 +256,7 @@ Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/types.ts:95`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) @@ -276,7 +276,7 @@ Source: [`packages/interaction/commands/src/types.ts:95`](../packages/interactio 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/types.ts:88`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts) ### `compaction/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 65ec0e3fbd..b787c8c30e 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -258,7 +258,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/commands/src/types.ts:95`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) @@ -278,7 +278,7 @@ export type SessionEvent = { 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -来源:[`packages/interaction/commands/src/types.ts:88`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts) ### `compaction/*` diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index da117e628a..ae8e96ce4d 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/attachment.md -attachment.md: ff7f14ceae8d4f8055d5cfd4367373729dc5ecbc -attachment.zh.md: 63769e621b8e088cda9bd49fbf23d08db8058669 +attachment.md: 7955850a55967861e287e22db5bea94e4f80fda4 +attachment.zh.md: d58dee7b8809b97a409acac999cde91d96b81ed9 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index ff7f14ceae..7955850a55 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -50,6 +50,18 @@ The reference records intrinsic dimensions and encoded length so clients can lay ## Commit and verified-read payloads +```ts type-equiv +/** Base64-encoded image upload accompanying one wire request. */ +interface EncodedImageAttachment { + /** Declared media type, verified against the decoded bytes during admission. */ + mediaType: ImageMediaType + /** Canonical base64 encoding of the image bytes. */ + data: string + /** Optional display name; it is never interpreted as a path. */ + name?: string +} +``` + ```ts type-equiv /** Request to validate and durably commit one image. */ interface SaveImageAttachment { @@ -69,7 +81,7 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `admitEncodedImages()` is the packaged batch caller for base64 wire uploads: it enforces the count and aggregate-byte limits, validates the whole batch, then commits and returns references in caller order. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. @@ -111,5 +123,5 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` -Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 63769e621b..d58dee7b88 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -50,6 +50,18 @@ interface ImageAttachmentLimits { ## 提交与经校验读取的数据 +```ts type-equiv +/** Base64-encoded image upload accompanying one wire request. */ +interface EncodedImageAttachment { + /** Declared media type, verified against the decoded bytes during admission. */ + mediaType: ImageMediaType + /** Canonical base64 encoding of the image bytes. */ + data: string + /** Optional display name; it is never interpreted as a path. */ + name?: string +} +``` + ```ts type-equiv /** Request to validate and durably commit one image. */ interface SaveImageAttachment { @@ -69,7 +81,7 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`admitEncodedImages()` 是面向 base64 wire 上传的封装批量调用方:强制执行张数与聚合字节上限,先校验整个批量,再提交并按调用方顺序返回引用。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 @@ -111,5 +123,5 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` -Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index e01ca50997..5f59598353 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/commands.md -commands.md: a4589d875fafdda7404e8c2d54fb739a4e945990 -commands.zh.md: 460784442257cc081fb73646c51885a432efadb5 +commands.md: eb08681a79b815a0c05fd0ef226e3415aaab0fe0 +commands.zh.md: 17f18ce41d8be67828037506ba235c39a88ab769 diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index a4589d875f..eb08681a79 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -8,13 +8,21 @@ Source: [`packages/interaction/commands/src/index.ts`](../../packages/interactio ## Input metadata -The service exposes one optional unstructured-input hint. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. +The service exposes one optional unstructured-input descriptor: a hint plus an image-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string + /** + * Whether composer image attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying images and capable + * composers refuse the submission before dispatch. A declaring command's + * handler receives the admitted durable blocks and owns every further + * grammar decision, including rejecting sub-commands that cannot use them. + */ + readonly images?: boolean } ``` @@ -55,6 +63,14 @@ interface CommandInvocation { readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string + /** + * Durably admitted image blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.images`. The handler + * owns their model-visible use — the registry never schedules them itself — + * and a handler whose grammar cannot use them in this invocation returns an + * error so the dispatching composer retains the originals. + */ + readonly attachments: readonly ImageBlock[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -150,18 +166,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * + * Image admission is enforced here, not in the composer: images sent to a + * command that does not declare `input.images`, an absent attachment store, + * and an exceeded attachment limit each settle as an error result before + * the handler runs, and a rejected batch publishes no durable object. + * * @param agent - exact receiving agent. * @param line - complete slash-command line. + * @param images - base64-encoded composer images accompanying the line, in + * submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.md) +Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md) -Source: [`packages/interaction/commands/src/index.ts:225`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:245`](../../packages/interaction/commands/src/index.ts) @@ -183,5 +206,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/types.ts:72`](../../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:80`](../../packages/interaction/commands/src/types.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 4607844422..17f18ce41d 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -8,13 +8,21 @@ ## 输入元数据 -该服务公开一个可选的非结构化输入提示。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 +该服务公开一个可选的非结构化输入描述符:提示文本加图片接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string + /** + * Whether composer image attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying images and capable + * composers refuse the submission before dispatch. A declaring command's + * handler receives the admitted durable blocks and owns every further + * grammar decision, including rejecting sub-commands that cannot use them. + */ + readonly images?: boolean } ``` @@ -55,6 +63,14 @@ interface CommandInvocation { readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string + /** + * Durably admitted image blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.images`. The handler + * owns their model-visible use — the registry never schedules them itself — + * and a handler whose grammar cannot use them in this invocation returns an + * error so the dispatching composer retains the originals. + */ + readonly attachments: readonly ImageBlock[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -150,18 +166,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * + * Image admission is enforced here, not in the composer: images sent to a + * command that does not declare `input.images`, an absent attachment store, + * and an exceeded attachment limit each settle as an error result before + * the handler runs, and a rejected batch publishes no durable object. + * * @param agent - exact receiving agent. * @param line - complete slash-command line. + * @param images - base64-encoded composer images accompanying the line, in + * submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.md) +Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md) -Source: [`packages/interaction/commands/src/index.ts:225`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:245`](../../packages/interaction/commands/src/index.ts) @@ -183,5 +206,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/types.ts:72`](../../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:80`](../../packages/interaction/commands/src/types.ts) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index bebd5ee4e7..d7ba2dc1b8 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: baeeca0cf939f1a3d4608769b362d532507b90f5 -README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e +README.md: f08568b4e12573418382c2b7138d2ecfeb598678 +README.zh.md: 94b523034438436175f7387df97fb51367e9da35 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index baeeca0cf9..f08568b4e1 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -6,6 +6,8 @@ The durable attachment seam. `ctx.attachments` validates and atomically commits Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. +`admitEncodedImages(attachments, images)` is the shared wire-batch admission used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64, the per-message count limit, and the aggregate byte limit from `imageLimits`, validates the whole batch, then commits every member and returns `ImageAttachmentRef`s in caller order; a rejected batch publishes no durable object. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. + ## Model Experience Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 238b90794c..94b5230344 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -6,6 +6,8 @@ 未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 +`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的批量准入函数:它按 `imageLimits` 强制执行规范 base64、单条消息张数上限与聚合字节上限,先校验整个批量,再提交每个成员并按调用方顺序返回 `ImageAttachmentRef`;被拒绝的批量不会发布任何持久化对象。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 + ## 模型体验 该包通过角色无关的核心 `ImageBlock`,以及解析其持久引用的提供方适配器,间接影响模型。 diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 3f11676e71..3ba376d0bf 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -16,10 +16,11 @@ "exports": { ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/packages/attachment/attachment/src/admission.ts b/packages/attachment/attachment/src/admission.ts new file mode 100644 index 0000000000..6eae365ebd --- /dev/null +++ b/packages/attachment/attachment/src/admission.ts @@ -0,0 +1,56 @@ +/** Batch admission of base64-encoded image uploads. @module @deepseek-ai/dsh-attachment/admission */ + +import { Buffer } from 'node:buffer' +import { AttachmentError } from './error.ts' +import type { AttachmentStore } from './index.ts' +import type { EncodedImageAttachment, ImageAttachmentRef, SaveImageAttachment } from './types.ts' + +/** Decode one upload payload while rejecting non-canonical base64 forms. */ +function decodeBase64(data: string): Uint8Array { + const decoded = Buffer.from(data, 'base64') + if (data.length === 0 || decoded.toString('base64') !== data) { + throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + } + return new Uint8Array(decoded) +} + +/** Store input for one decoded upload. */ +function saveInput(image: EncodedImageAttachment, data: Uint8Array): SaveImageAttachment { + return { + data, + mediaType: image.mediaType, + ...image.name === undefined ? {} : { name: image.name }, + } +} + +/** + * Validate one wire image batch against the per-message limits and durably + * commit every member. The whole batch is validated before any member is + * saved, so a rejected batch publishes no durable object. + * @param attachments - the deployment attachment store enforcing per-image policy. + * @param images - base64-encoded uploads in caller order. + * @returns durable references in the same order as `images`. + * @throws AttachmentError on a non-canonical payload or an exceeded batch limit. + */ +export async function admitEncodedImages( + attachments: AttachmentStore, + images: readonly EncodedImageAttachment[], +): Promise { + const limits = attachments.imageLimits + if (images.length > limits.maxImagesPerMessage) { + throw new AttachmentError('Upload exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + } + const decoded = images.map(image => ({ image, data: decodeBase64(image.data) })) + const totalBytes = decoded.reduce((sum, item) => sum + item.data.byteLength, 0) + if (totalBytes > limits.maxMessageImageBytes) { + throw new AttachmentError('Upload exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') + } + for (const item of decoded) { + await attachments.validateImage(saveInput(item.image, item.data)) + } + const refs: ImageAttachmentRef[] = [] + for (const item of decoded) { + refs.push(await attachments.saveImage(saveInput(item.image, item.data))) + } + return refs +} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 1bfb1ea119..d1bdebfd1d 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -10,8 +10,10 @@ import type { export { AttachmentId } from './brand.ts' export { AttachmentError } from './error.ts' +export { admitEncodedImages } from './admission.ts' export type { AttachmentId as AttachmentIdType, + EncodedImageAttachment, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 102209553b..31ff2b2b2d 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -32,6 +32,16 @@ export interface ImageAttachmentLimits { mediaTypes: readonly ImageMediaType[] } +/** Base64-encoded image upload accompanying one wire request. */ +export interface EncodedImageAttachment { + /** Declared media type, verified against the decoded bytes during admission. */ + mediaType: ImageMediaType + /** Canonical base64 encoding of the image bytes. */ + data: string + /** Optional display name; it is never interpreted as a path. */ + name?: string +} + /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { data: Uint8Array diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts new file mode 100644 index 0000000000..90760c5c09 --- /dev/null +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' + +/** One-pixel valid payloads are irrelevant here: the store below accepts any decoded bytes. */ +const PNG = 'AAAA' // canonical base64, 3 bytes + +function refOf(input: SaveImageAttachment, ordinal: number): ImageAttachmentRef { + return { + attachmentId: `att-${ordinal}` as ImageAttachmentRef['attachmentId'], + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, + } +} + +/** In-memory store double recording call order; limits are per-test. */ +function storeOf(limits?: Partial) { + const calls: string[] = [] + let saved = 0 + const store = { + imageLimits: { + maxImageBytes: 1024, + maxImagesPerMessage: 4, + maxMessageImageBytes: 1024, + maxImagePixels: 1_000_000, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], + ...limits, + }, + validateImage: vi.fn((input: SaveImageAttachment) => { + calls.push(`validate:${input.name ?? input.mediaType}`) + return Promise.resolve() + }), + saveImage: vi.fn((input: SaveImageAttachment) => { + calls.push(`save:${input.name ?? input.mediaType}`) + saved += 1 + return Promise.resolve(refOf(input, saved)) + }), + } + return { store: store as unknown as AttachmentStore, calls, mocks: store } +} + +describe('admitEncodedImages', () => { + it('validates the whole batch before saving any member and returns refs in caller order', async () => { + const { store, calls } = storeOf() + const refs = await admitEncodedImages(store, [ + { mediaType: 'image/png', data: PNG, name: 'first.png' }, + { mediaType: 'image/jpeg', data: PNG, name: 'second.jpg' }, + ]) + expect(calls).toEqual(['validate:first.png', 'validate:second.jpg', 'save:first.png', 'save:second.jpg']) + expect(refs.map(ref => ref.name)).toEqual(['first.png', 'second.jpg']) + expect(refs.map(ref => ref.attachmentId)).toEqual(['att-1', 'att-2']) + }) + + it('omits the name from store inputs when the upload has none', async () => { + const { store, mocks } = storeOf() + const refs = await admitEncodedImages(store, [{ mediaType: 'image/webp', data: PNG }]) + expect(mocks.saveImage).toHaveBeenCalledWith({ data: expect.any(Uint8Array) as unknown, mediaType: 'image/webp' }) + expect(refs[0]?.name).toBeUndefined() + }) + + it('admits an empty batch without touching the store', async () => { + const { store, mocks } = storeOf() + await expect(admitEncodedImages(store, [])).resolves.toEqual([]) + expect(mocks.validateImage).not.toHaveBeenCalled() + expect(mocks.saveImage).not.toHaveBeenCalled() + }) + + it('rejects a batch above the image-count limit before decoding', async () => { + const { store, mocks } = storeOf({ maxImagesPerMessage: 1 }) + const batch = [ + { mediaType: 'image/png' as const, data: PNG }, + { mediaType: 'image/png' as const, data: 'not base64!!' }, + ] + await expect(admitEncodedImages(store, batch)).rejects.toMatchObject({ + name: 'AttachmentError', + code: 'TOO_MANY_IMAGES', + }) + expect(mocks.saveImage).not.toHaveBeenCalled() + }) + + it('rejects a batch above the aggregate byte limit without saving', async () => { + const { store, mocks } = storeOf({ maxMessageImageBytes: 5 }) + await expect(admitEncodedImages(store, [ + { mediaType: 'image/png', data: PNG }, + { mediaType: 'image/png', data: PNG }, + ])).rejects.toMatchObject({ code: 'IMAGES_TOO_LARGE' }) + expect(mocks.saveImage).not.toHaveBeenCalled() + }) + + it('admits a batch exactly at both limits', async () => { + const { store } = storeOf({ maxImagesPerMessage: 2, maxMessageImageBytes: 6 }) + await expect(admitEncodedImages(store, [ + { mediaType: 'image/png', data: PNG }, + { mediaType: 'image/png', data: PNG }, + ])).resolves.toHaveLength(2) + }) + + it('rejects non-canonical and empty base64 payloads', async () => { + const { store, mocks } = storeOf() + for (const data of ['', 'AAA', '!!!!']) { + await expect(admitEncodedImages(store, [{ mediaType: 'image/png', data }])) + .rejects.toMatchObject({ code: 'INVALID_IMAGE_BASE64' }) + } + expect(mocks.saveImage).not.toHaveBeenCalled() + }) + + it('propagates a store validation failure without saving any member', async () => { + const { store, mocks } = storeOf() + mocks.validateImage.mockRejectedValueOnce(new AttachmentError('too many pixels', 'IMAGE_TOO_MANY_PIXELS')) + await expect(admitEncodedImages(store, [ + { mediaType: 'image/png', data: PNG }, + { mediaType: 'image/png', data: PNG }, + ])).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + expect(mocks.saveImage).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dd0566486e..9da78d4526 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1733,13 +1733,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { value: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', images: true } }, { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, ], } }, - execute(id: SessionId, line: string): RpcResult { + execute(id: SessionId, line: string, images: readonly unknown[] = []): RpcResult { const missing = requireGoalSession(id) if (missing !== undefined) return missing // Structured split mirroring the Host parser: name + verbatim rawInput @@ -1747,6 +1747,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const match = /^\/(\S+)((?:\s.*)?)$/.exec(line.trim()) const name = match?.[1] const args = match?.[2] ?? '' + // Mirror the Host executor's declaration enforcement: only the + // descriptors listed with `input.images` accept an image-carrying + // submission; the fixture stores no bytes, so accepted images are + // acknowledged and dropped. + if (images.length > 0 && name !== 'goal' && name !== 'plan') { + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name: name ?? '', args, source: { kind: 'user' } } }) + const result: CommandResult = { kind: 'error', text: `/${name} does not accept image attachments` } + append(id, { type: 'command/done', data: { commandId, ...result } }) + return { ok: true, value: { commandId, result } } + } if (name === 'permission') { const preset = args.trim() const commandId = `fx-cmd-${logOf(id).length}` as CommandId @@ -3004,6 +3015,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { args: { agentId: SessionId line?: string + images?: readonly unknown[] ref?: { id: string; revision: number } request?: { objective?: string; maxGoalRounds?: number } } @@ -3011,7 +3023,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const sessionId = args.agentId switch (endpoint) { case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) - case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string)) + case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: args.request?.objective as string, ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index 909d0baf46..c7bf0e58c8 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -28,13 +28,15 @@ const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${reqCount++}`) describe('createFixtureApi commands/skills', () => { it('serves the addressed session catalog', async () => { const { rpc } = createFixtureFaces() - const commands = await callRemote<{ name: string; input?: { hint: string } }[]>( + const commands = await callRemote<{ name: string; input?: { hint: string; images?: boolean } }[]>( rpc, 'commands/list', { agentId: sid('fx-alpha') }) expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') expect(echo?.input?.hint).toBeTruthy() expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined() + // Image acceptance is declared per descriptor; only goal and plan carry it. + expect(commands.filter(c => c.input?.images === true).map(c => c.name)).toEqual(['goal', 'plan']) }) it('rejects a catalog request for an unknown session', async () => { @@ -80,6 +82,40 @@ describe('createFixtureApi commands/skills', () => { expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) + it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => { + const { api, rpc } = createFixtureFaces() + const frames: unknown[] = [] + const abort = new AbortController() + const stream = api.events.mux(req({}), abort.signal) + const pump = (async () => { + for await (const frame of stream) { + frames.push(frame.payload) + if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() + } + })() + const png = { mediaType: 'image/png', data: 'AA==' } + const refused = await callRemote<{ commandId: string; result: { kind: string; text?: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hi', images: [png] }) + expect(refused?.commandId).toBeTruthy() + expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' }) + await pump + const events = frames + .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') + .map(f => f.event) + expect(events).toMatchObject([ + { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } }, + { type: 'command/done', data: { kind: 'error', text: '/echo does not accept image attachments' } }, + ]) + }) + + it('a declaring command accepts an image-carrying execute', async () => { + const { rpc } = createFixtureFaces() + const png = { mediaType: 'image/png', data: 'AA==' } + const accepted = await callRemote<{ result: { kind: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship it', images: [png] }) + expect(accepted?.result.kind).toBe('success') + }) + it('answers no execution for unknown names and non-command lines', async () => { const { rpc } = createFixtureFaces() for (const line of ['/nope', 'plain text', '/']) { diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 3e5ec5a811..f38e3c757b 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -356,7 +356,7 @@ export class Session implements SessionFace { * @returns the admission result, or the error branch on transport failure. */ async command(line: string): Promise> { - const result = await this.remote.commands.execute(this.sessionId, line) + const result = await this.remote.commands.execute(this.sessionId, line, []) if (!result.ok) return result return { ok: true, value: { matched: result.value !== undefined } } } diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 1c95c39494..1c99d20187 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md -README.md: 67110ffd8c1ad11e56ca9293a9064c66dd08c81d -README.zh.md: 40fe21850dd289d2a5c91bd88d4f22c087731b80 +README.md: 7d4a700f70eb93ce1feea6b88eeeae6643039445 +README.zh.md: 0896ee0393ab93927b6b7ce2712028e3e0825ba3 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 67110ffd8c..7d4a700f70 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -8,6 +8,8 @@ Client command API (`ctx.commandUi`): the session-keyed command-directory cache, `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +`matchEnter` also enforces the submission envelope: when the composer submits with image attachments, only a host command declaring `input.images` proceeds (its claim carries `images: true` and its submit forwards the serialized payloads to `command.execute`); every other command route — contribution popup, decorated popup, non-declaring claim, bare detached execute — throws the localized `notice.imagesUnsupported` refusal, which the input machine renders as one composer notice with the draft and images retained. An image-carrying submit whose host handler answers an error result maps to an error outcome so the composer keeps the images; imageless submits keep the plain success mapping because the durable flow node owns the outcome rendering. + After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. Listener failures are logged and contained one by one; they cannot change the already-admitted command result or prevent later listeners from running. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 40fe21850d..0896ee0393 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -8,6 +8,8 @@ `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +`matchEnter` 还强制执行提交信封:composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续(其 claim 携带 `images: true`,其 submit 把序列化载荷转交 `command.execute`);其余每条命令路径——contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行——都会抛出本地化的 `notice.imagesUnsupported` 拒绝,输入状态机将其渲染为一条 composer 通知,草稿与图片原样保留。带图提交若宿主处理器返回错误结果,则映射为错误 outcome,composer 保留图片;不带图的提交维持原有的一律成功映射,因为结果呈现由持久化 flow 节点负责。 + `command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。监听器失败会逐项记录并隔离,不会改变已经准入的命令结果,也不会阻止后续监听器运行。 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和贡献项顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 63c5862cf2..1d04fe7eb7 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { 'status.empty': '无选项', 'overlay.aria': '/{command} 选项', 'listbox.aria': '/{command} 匹配项', + 'notice.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', } satisfies Record /** The command namespace key union. */ @@ -23,4 +24,5 @@ export const en = { 'status.empty': 'No options', 'overlay.aria': '/{command} options', 'listbox.aria': '/{command} matches', + 'notice.imagesUnsupported': '/{command} does not accept image attachments; remove them first', } satisfies Record diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index f9253c80d5..5db2033e29 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -14,9 +14,10 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, - SubmitOutcome, + SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' @@ -122,6 +123,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { private readonly directory: CommandDirectory private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() } + /** `command`-namespace translator (composer refusal notices). */ + private readonly t: TranslateNS<'command'> /** * @param ctx - owning root context (plugin fiber; the service registers @@ -129,6 +132,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract { */ constructor(ctx: Context) { super(ctx, 'commandUi') + const locale = ctx.get('locale') + if (locale === undefined) throw new Error('ui-commands: locale service unavailable') + this.t = locale.bind('command') this.directory = new CommandDirectory(async (sessionId) => { if (this.sessions().subagentAddress(sessionId) !== undefined) return [] const result = await ctx.remote.commands.list(sessionId) @@ -143,7 +149,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { candidates: (session, req) => this.candidates(session, req), onPick: pick => this.dispatch(pick), matchSpace: (session, token) => this.matchSpace(session, token), - matchEnter: (session, line, signal) => this.matchEnter(session, line, signal), + matchEnter: (session, line, signal, envelope) => this.matchEnter(session, line, signal, envelope), warm: (session) => { this.directory.warm(session.sessionId) }, }), 'command: slash source') ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() }) @@ -302,8 +308,19 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * warmup failure rejects — never a silent downgrade). Contributions and * bare host commands act on the bare token only; leadingInput claims * args-tolerant. + * + * Envelope policy: an enter submission carrying images resolves only + * through a command declaring image acceptance. Every other command route — + * popup, non-accepting claim, bare detached execute — throws the refusal + * so the machine surfaces one composer notice and the draft and images + * stay in place; nothing executes and nothing is dropped. */ - private async matchEnter(session: ClientSessionContext, line: string, signal: AbortSignal): Promise { + private async matchEnter( + session: ClientSessionContext, + line: string, + signal: AbortSignal, + envelope: SubmitEnvelope, + ): Promise { const trimmed = line.trim() if (!trimmed.startsWith('/')) return undefined const ws = trimmed.search(/\s/) @@ -311,9 +328,13 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const bare = ws === -1 const name = token.slice(1) if (name === '') return undefined + const refuseImages = (): never => { + throw new Error(this.t('notice.imagesUnsupported', { command: name })) + } const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined + if (envelope.images > 0) refuseImages() this.openPopup(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } @@ -325,12 +346,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (bare) { const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(session)) { + if (envelope.images > 0) refuseImages() this.openPopup(name, decoration.ui, session, { via: 'enter', token }) return 'handled' } } - if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) } + if (desc.input !== undefined) { + if (envelope.images > 0 && desc.input.images !== true) refuseImages() + return { claim: this.leadingClaim(desc, session) } + } if (!bare) return undefined + if (envelope.images > 0) refuseImages() this.consumeVia(session.sessionId, { via: 'enter', token }) this.runDetached(desc, session, trimmed) return 'handled' @@ -354,7 +380,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return { token, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - submit: (args, _actx) => this.execute(session, token + args), + ...(desc.input?.images === true ? { images: true } : {}), + submit: (args, _actx, images) => this.execute(session, token + args, images), } } @@ -365,16 +392,24 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * plain success regardless of its handler outcome, because the host * executor durably logged the lifecycle (`command/run`/`command/done`) and * the outcome renders as a persistent flow node — the composer never - * echoes it. Transport failures throw. + * echoes it. A handler error result reports an error outcome so the + * composer keeps the submission (draft and images) for correction. + * Transport failures throw. */ private async execute( session: ClientSessionContext, line: string, + images: readonly SubmitImageAttachment[] = [], ): Promise { - const result = await this.ctx.remote.commands.execute(session.sessionId, line) + const result = await this.ctx.remote.commands.execute(session.sessionId, line, images) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result) + // An image-carrying submission consumed its images only on handler + // success; an error outcome keeps draft and images in the composer. + if (images.length > 0 && result.value.result.kind === 'error') { + return { kind: 'error', text: result.value.result.text } + } return { kind: 'success' } } diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 08fa1de833..1d3bc796f4 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandUiRuntime } from '../src/client/service.ts' @@ -32,7 +32,7 @@ const S2_CMDS: CommandDescriptor[] = [ { name: 'attach', description: 'scoped shadow', input: { hint: 'path' } }, ] -type ExecuteValue = { matched: boolean; commandId?: string } +type ExecuteValue = { matched: boolean; commandId?: string; result?: CommandResult } interface BenchOptions { /** Scripted catalog per list payload; default serves the fixed catalogs by session. */ @@ -67,7 +67,7 @@ async function bench(opts: BenchOptions = {}) { const ctx = new Context() const registered = new Map() const listCalls: Array<{ sessionId: SessionId }> = [] - const executeCalls: Array<{ sessionId: SessionId; line: string }> = [] + const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitImageAttachment[] }> = [] // The service reads the generated commands Remote, which delivers the // carrier's outcome, so a programmed failure answers the error branch. const commandsRemote = { @@ -80,13 +80,13 @@ async function bench(opts: BenchOptions = {}) { return value.commands }) }, - execute: async (sessionId: SessionId, line: string) => { - executeCalls.push({ sessionId, line }) + execute: async (sessionId: SessionId, line: string, images: readonly SubmitImageAttachment[] = []) => { + executeCalls.push({ sessionId, line, images }) return await carried(async () => { const fallback = (): Promise => Promise.resolve({ matched: true }) const value = await (opts.execute ?? fallback)({ sessionId, line }) return value.matched - ? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } } + ? { commandId: value.commandId ?? 'fake-command', result: value.result ?? { kind: 'success' as const } } : undefined }) }, @@ -98,6 +98,11 @@ async function bench(opts: BenchOptions = {}) { return () => { registered.delete(key) } }, }) + // Deterministic key-echo translator: notice assertions read `key{json}`. + ctx.provide('locale', { + bind: (ns: string) => (key: string, params?: Record) => + `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`, + }) // Real scope tags behind a fake sessions face. const scopes = new Map } }>() ctx.provide('sessions', { @@ -297,9 +302,9 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration()) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled') + expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { images: 0 })).toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' }) - const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal) + const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { images: 0 }) if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim') expect(argued.claim.token).toBe('/goal ') }) @@ -318,7 +323,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration({ name: 'phantom' })) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined() + expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { images: 0 })).toBeUndefined() expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined() expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) }) @@ -327,8 +332,8 @@ describe('decorations (bare-invocation UI on host commands)', () => { const { command, source, warm, executeCalls } = await bench() command.decorate(goalDecoration({ name: 'plan', available: () => false })) await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled') - expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })).toBe('handled') + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) }) it('duplicate decoration names fail loud', async () => { @@ -383,7 +388,7 @@ describe('dispatch (menu column)', () => { expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled') expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }]) await vi.waitFor(() => { - expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) expect(executions).toEqual([{ sessionId: sid('s1'), name: 'plan', @@ -440,7 +445,7 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => new Promise((resolve) => { release = resolve }), }) - const wait = source.matchEnter!(proj('s1'), '/goal args', signal()) + const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { images: 0 }) release({ commands: S1_CMDS }) const outcome = await wait if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') @@ -451,14 +456,14 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => Promise.reject(new Error('warmup boom')), }) - await expect(source.matchEnter!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom') + await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { images: 0 })).rejects.toThrow('warmup boom') }) it('leadingInput claims args-tolerant (bare and with trailing text)', async () => { const { source, warm } = await bench() await warm(proj('s1')) for (const line of ['/goal', '/goal refactor the loop']) { - const outcome = await source.matchEnter!(proj('s1'), line, signal()) + const outcome = await source.matchEnter!(proj('s1'), line, signal(), { images: 0 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') expect(outcome.claim.token).toBe('/goal ') } @@ -473,16 +478,16 @@ describe('matchEnter (enter column)', () => { return true }) await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan', signal())).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 0 })).resolves.toBe('handled') expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }]) await Promise.resolve() - expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) }) it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => { const { source, warm, executeCalls } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan now', signal())).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { images: 0 })).resolves.toBeUndefined() expect(executeCalls).toEqual([]) }) @@ -490,18 +495,86 @@ describe('matchEnter (enter column)', () => { const { command, source, mint, listCalls } = await bench() command.register(themeContribution()) const scope = mint('s1') - await expect(source.matchEnter!(proj('s1'), '/theme', signal())).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 0 })).resolves.toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true) expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady - await expect(source.matchEnter!(proj('s1'), '/theme dark', signal())).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { images: 0 })).resolves.toBeUndefined() }) it('unknown name, bare "/", and non-slash lines → undefined', async () => { const { source, warm } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/nope', signal())).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), '/', signal())).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), 'plain text', signal())).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { images: 0 })).resolves.toBeUndefined() + }) +}) + +describe('matchEnter envelope policy (images)', () => { + const signal = () => new AbortController().signal + const IMG_CMDS: CommandDescriptor[] = [ + ...S1_CMDS, + { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', images: true } }, + ] + const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } + + it('a leadingInput command not declaring acceptance refuses; a declaring one claims with images minted', async () => { + const { source, warm } = await bench({ commands: () => Promise.resolve({ commands: IMG_CMDS }) }) + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { images: 1 })) + .rejects.toThrow('command:notice.imagesUnsupported{"command":"goal"}') + const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { images: 1 }) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + expect(outcome.claim.token).toBe('/vision ') + expect(outcome.claim.images).toBe(true) + }) + + it('bare popup routes refuse images: contribution and decorated host both stay closed', async () => { + const { command, source, mint, warm } = await bench() + command.register(themeContribution()) + command.decorate({ name: 'plan', available: () => true, ui: themeUi() }) + const scope = mint('s1') + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 1 })) + .rejects.toThrow('command:notice.imagesUnsupported{"command":"theme"}') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 2 })) + .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) + }) + + it('bare host detached execute refuses images before any RPC', async () => { + const { source, warm, executeCalls } = await bench() + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 1 })) + .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + expect(executeCalls).toEqual([]) + }) + + it('claim.submit forwards the images to execute; consumption follows the handler outcome', async () => { + let result: CommandResult = { kind: 'error', text: 'handler refused' } + const { source, warm, executeCalls } = await bench({ + commands: () => Promise.resolve({ commands: IMG_CMDS }), + execute: () => Promise.resolve({ matched: true, result }), + }) + await warm(proj('s1')) + const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { images: 1 }) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + // Handler error: the error outcome keeps draft and images in the composer. + await expect(outcome.claim.submit('x', new Context(), [png])) + .resolves.toEqual({ kind: 'error', text: 'handler refused' }) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/vision x', images: [png] }]) + result = { kind: 'success', text: 'described' } + await expect(outcome.claim.submit('x', new Context(), [png])).resolves.toEqual({ kind: 'success' }) + }) + + it('an imageless submission keeps the always-success admission mapping over a handler error', async () => { + const { source, warm } = await bench({ + execute: () => Promise.resolve({ matched: true, result: { kind: 'error', text: 'late failure' } }), + }) + await warm(proj('s1')) + const outcome = source.matchSpace!(proj('s1'), '/goal') + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + await expect(outcome.claim.submit('x', new Context(), [])).resolves.toEqual({ kind: 'success' }) }) }) @@ -513,8 +586,8 @@ describe('execute payload', () => { await warm(proj('s1')) const outcome = source.matchSpace!(proj('s1'), '/goal') if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') - const settled = await outcome.claim.submit('ship it', new Context()) - expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }]) + const settled = await outcome.claim.submit('ship it', new Context(), []) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }]) // Pure admission: no outcome text ever rides the submit result — the // durable command lifecycle events render the outcome in the flow. expect(settled).toEqual({ kind: 'success' }) @@ -539,7 +612,7 @@ describe('execute payload', () => { b.ctx.on('command/executed', rejectingListener) b.ctx.on('command/executed', after) - await expect(outcome.claim.submit('ship it', new Context())).resolves.toEqual({ kind: 'success' }) + await expect(outcome.claim.submit('ship it', new Context(), [])).resolves.toEqual({ kind: 'success' }) expect(after).toHaveBeenCalledOnce() await Promise.resolve() await Promise.resolve() @@ -557,10 +630,10 @@ describe('execute payload', () => { return outcome.claim } const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) }) - const bad = await first.submit('x', new Context()) + const bad = await first.submit('x', new Context(), []) expect(bad.kind).toBe('error') const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) }) - await expect(second.submit('', new Context())).resolves.toEqual({ kind: 'success' }) + await expect(second.submit('', new Context(), [])).resolves.toEqual({ kind: 'success' }) }) }) @@ -584,7 +657,7 @@ describe('detached admission notices', () => { // Admission miss (matched:false): immediate composer feedback stays. mode = 'miss' - await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal) + await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 }) await flush() expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }]) @@ -663,7 +736,7 @@ describe('popupFor', () => { consumes.push(r) return true }) - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) const popup = command.popupFor(scope.ctx) await Promise.resolve() await popup.select(0) @@ -674,7 +747,7 @@ describe('popupFor', () => { const { command, source, mint } = await bench() command.register(themeContribution()) const scope = mint('s1') - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) const popup = command.popupFor(scope.ctx) expect(popup.state.getSnapshot().open).toBe(true) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6d866e05e7..080051a1b8 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: d1a265b5789d9f1d9b5e630e0548ae5f619eebbf -README.zh.md: 3f303391d39bc040b4a6a5a2d1f6a34fe8891919 +README.md: d9b774bdf5bfc2beaa33fe0d3ada8263b863798d +README.zh.md: 94da3def811fb901132f53fd6dbf4de0ccd6b3c8 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d1a265b578..d9b774bdf5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,7 +36,7 @@ Keyboard message submission resolves delivery from the addressed session's runni Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code. +Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code. Attached images are part of the submission envelope on every send path: a slash-command submit either consumes them (a claim declaring `images` has them serialized through the hub's `commandImages` plumbing, passed to `claim.submit`, and cleared plus released only on a success outcome) or refuses the whole submission with the `command.imagesUnsupported` notice while draft and images stay in place — a command can never consume the text and strand the images. The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `InputTriggerController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-input-trigger's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3f303391d3..94da3def81 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,7 +36,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 逐会话 UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。 +图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。已附加的图片在每条发送路径上都是提交信封的一部分:斜杠命令提交要么消费它们(声明 `images` 的 claim 经 hub 的 `commandImages` 管道序列化图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放),要么以 `command.imagesUnsupported` 通知拒绝整个提交,草稿与图片原样保留——命令不可能消费了文字却把图片留在原地。 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `InputTriggerController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-input-trigger 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index bad1e55d75..25d90f1312 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -214,7 +214,7 @@ export interface InputState { readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ - readonly claim?: { readonly token: string; readonly hint?: string } + readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } /** Chip occurrence table, sorted by offset (one U+FFFC per entry). */ readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 35aa9197a2..e781e783c9 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -10,7 +10,7 @@ import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, InputTriggerController, TokenSpan, + ReferenceInsert, InputTriggerController, SubmitImageAttachment, TokenSpan, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, @@ -46,6 +46,15 @@ export interface SessionInputDeps { steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink(text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode): void + /** Command-plane image plumbing (the hub owns the conversation face and the copy). */ + commandImages: { + /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */ + serialize(ids: readonly DraftAttachmentId[]): Promise + /** Free consumed draft images after a successful command submit. */ + release(ids: readonly DraftAttachmentId[]): void + /** Localized composer notice for a claimed command that does not accept images. */ + unsupportedNotice(token: string): string + } } /** Guard tier from the machine phase. */ @@ -200,6 +209,15 @@ export class SessionInputShell implements SessionInput { if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds], mode) return } + // Claimed pre-gate: a claim that does not declare image acceptance never + // submits while images are attached — one notice, everything retained. + // Enter-time adjudication applies the same policy for unclaimed lines + // inside the command source itself. + const before = this.snapshot + if (before.phase === 'claimed' && this.imageIds.length > 0 && before.claim?.images !== true) { + this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft)) + return + } this.run(this.core.dispatch({ type: 'enter', mode })) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { @@ -456,7 +474,7 @@ export class SessionInputShell implements SessionInput { this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) return } - inputTriggers.adjudicate(draft.trim(), attempt.signal).then( + inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then( (outcome: PickOutcome) => { if (this.dead(attempt)) return this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome })) @@ -469,13 +487,26 @@ export class SessionInputShell implements SessionInput { ) } - /** The submit transaction: claim.submit against the session scope; ok maps from the outcome kind. */ + /** + * The submit transaction: claim.submit against the session scope; ok maps + * from the outcome kind. An accepting claim receives the serialized draft + * images, which are cleared and released only on a success outcome; a + * failure (serialize, transport, or handler error) keeps draft and images + * for correction. + */ private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void { + const imageIds = claim.images === true ? [...this.imageIds] : [] Promise.resolve() - .then(() => claim.submit(args, this.deps.actx)) + .then(() => imageIds.length > 0 ? this.deps.commandImages.serialize(imageIds) : []) + .then(images => claim.submit(args, this.deps.actx, images)) .then( (outcome) => { if (this.dead(attempt)) return + if (outcome.kind === 'success' && imageIds.length > 0) { + const submitted = new Set(imageIds) + this.imageIds = this.imageIds.filter(id => !submitted.has(id)) + this.deps.commandImages.release(imageIds) + } this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, })) diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 54e09200ce..0cc3408620 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -9,7 +9,7 @@ * real host entity, so the sink is one unconditional prompt path. */ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { InputTriggerController } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { InputTriggerController, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, DraftAttachmentId, SessionInputResolver, SessionInput } from './contract.ts' @@ -30,6 +30,7 @@ interface ConversationAttachmentFace { imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, ): Promise + serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise releaseDraftImage(id: DraftAttachmentId): void } @@ -77,6 +78,16 @@ export class InputHub implements SessionInputResolver { queue: queueReadFaceOf(session), defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) }, steerQueue: () => { void this.steerQueue(session, shell) }, + commandImages: { + serialize: ids => this.conversation().serializeDraftImages(ids), + release: (ids) => { + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + for (const imageId of ids) conversation?.releaseDraftImage(imageId) + }, + unsupportedNotice: token => this.t('command.imagesUnsupported', { + command: token.trim().replace(/^\//u, ''), + }), + }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 25fdf2fc9a..f42b827d52 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -136,7 +136,15 @@ export class InputMachine { imageIds: [], draftRev: this.draftRev, phase: this.phase, - ...(c ? { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}) } } : {}), + ...(c + ? { + claim: { + token: c.token, + ...(c.hint !== undefined ? { hint: c.hint } : {}), + ...(c.images === true ? { images: true } : {}), + }, + } + : {}), occurrences: this.occurrences, ...(this.paste !== undefined ? { paste: this.paste } : {}), queue: EMPTY_QUEUE, diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c9b6f658ca..4fa830dd3c 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -133,6 +133,7 @@ export const zh = { 'command.failed': '命令失败', 'command.done': '已完成', 'command.title': '命令', + 'command.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', 'approval.waiting': '等待审批', 'approval.detail.aria': '审批详情', 'approval.escalation': '工具 {toolName} 请求越权执行', @@ -302,6 +303,7 @@ export const en = { 'command.failed': 'Command failed', 'command.done': 'Completed', 'command.title': 'Command', + 'command.imagesUnsupported': '/{command} does not accept image attachments; remove them first', 'approval.waiting': 'Waiting for approval', 'approval.detail.aria': 'Approval details', 'approval.escalation': 'Tool {toolName} requests privileged execution', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 198eee5f0f..9a25e13dc0 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,6 +13,7 @@ import type { Context } from '@deepseek-ai/cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' @@ -185,6 +186,21 @@ export class ConversationController extends Service implements IConversation { return attachments } + /** + * Serialize ordered draft images to command-submit wire payloads without + * sending or releasing them (the composer releases only after the command + * settles successfully). + * @param imageIds - ordered draft-local attachment ids. + * @returns base64 payloads in id order. + */ + async serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise { + const attachments = this.draftImages(imageIds) + if (attachments.length !== imageIds.length) { + throw new Error('conversation.serializeDraftImages: one or more draft images are no longer available') + } + return Promise.all(attachments.map(attachment => this.encodeImage(attachment.file))) + } + /** * Release one browser-owned draft image and preview URL. * @param id - draft attachment id. @@ -314,12 +330,16 @@ export class ConversationController extends Service implements IConversation { /** Convert browser files to canonical base64 prompt parts. */ private serializeImages(images: readonly File[]): Promise[0]> { - return Promise.all(images.map(async file => ({ - type: 'image' as const, + return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) }))) + } + + /** Canonical base64 wire form of one browser image file. */ + private async encodeImage(file: File): Promise { + return { mediaType: imageMediaType(file.type), data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), ...(file.name === '' ? {} : { name: file.name }), - }))) + } } } diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index f7d5e02a7f..daae177cff 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -114,6 +114,7 @@ function bench(over?: BenchOptions) { const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, + commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` }, queue: { getSnapshot: () => session.getSnapshot().queue, subscribe: fn => session.subscribe(fn), diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 9518318557..b173cae66c 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -12,9 +12,10 @@ import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' @@ -52,7 +53,12 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled keyboard: shell, addImages: () => null, removeImage: () => {}, - draftImages: () => [], + // Every id resolves so the bar's registry prune never drops a test image. + draftImages: ids => ids.map(id => ({ + kind: 'image' as const, id, + file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), + previewUrl: `blob:${id}`, + })), resolveSubmitMode: () => 'queue', toggleCommandMenu: vi.fn(), useNotices: bindSnapshotSelector(shell.notices), @@ -68,25 +74,33 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled return render() } -function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise }) { +function bench(over?: { + running?: boolean + disabled?: boolean + submit?: (args: string) => Promise + serialize?: (ids: readonly DraftAttachmentId[]) => Promise +}) { const sink = vi.fn() - const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink }) + const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve([]))) + const release = vi.fn() + const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) const wiring = shell const view = mountBar(shell, over) const textarea = view.container.querySelector('textarea')! - const claim = (token = '/goal ', hint = '目标') => { + const claim = (token = '/goal ', hint = '目标', images?: true) => { act(() => { shell.setDraft(token) shell.beginCommand( { token, hint, + ...(images === true ? { images: true } : {}), submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })), }, { start: 0, end: token.length, draftRev: shell.snapshot.draftRev }, ) }) } - return { view, textarea, shell, wiring, sink, claim } + return { view, textarea, shell, wiring, sink, claim, serialize, release } } describe('matrix row: plain', () => { @@ -122,7 +136,7 @@ describe('matrix row: claimed', () => { fireEvent.change(textarea, { target: { value: '/goal 发布' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).not.toHaveBeenCalled() - await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) }) + await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX, []) }) // Commit: draft cleared, notice surfaced, back to plain. await vi.waitFor(() => { expect((textarea).value).toBe('') }) expect(view.getByText('完成')).toBeTruthy() @@ -138,6 +152,68 @@ describe('matrix row: claimed', () => { }) }) +describe('matrix row: claimed with images', () => { + const img = 'img-1' as DraftAttachmentId + + it('a claim without image acceptance blocks enter: one notice, draft/images/claim retained', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) + const { view, textarea, shell, sink, claim } = bench({ submit }) + claim() + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await Promise.resolve() + expect(shell.snapshot.phase).toBe('claimed') + expect(submit).not.toHaveBeenCalled() + expect(sink).not.toHaveBeenCalled() + expect(view.getByText('/goal images-unsupported')).toBeTruthy() + expect(shell.snapshot.imageIds).toEqual([img]) + expect((textarea).value).toBe('/goal ') + }) + + it('an accepting claim serializes and forwards the images; success consumes and clears', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) + const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } + const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png]) }) + claim('/goal ', '目标', true) + // The claim currency carries the acceptance flag the pre-gate reads. + expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', images: true }) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png]) }) + expect(serialize).toHaveBeenCalledWith([img]) + await vi.waitFor(() => { expect((textarea).value).toBe('') }) + expect(release).toHaveBeenCalledWith([img]) + expect(shell.snapshot.imageIds).toEqual([]) + expect(shell.snapshot.phase).toBe('plain') + }) + + it('a handler error outcome keeps the images unreleased beside the notice and the draft', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'error' as const, text: '处理失败' })) + const { view, textarea, shell, claim, release } = bench({ submit }) + claim('/goal ', '目标', true) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(view.getByText('处理失败')).toBeTruthy() }) + expect(shell.snapshot.phase).toBe('claimed') + expect(shell.snapshot.imageIds).toEqual([img]) + expect(release).not.toHaveBeenCalled() + expect((textarea).value).toBe('/goal ') + }) + + it('a serialize rejection blocks the transaction: notice, no submit call, images kept', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) + const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.reject(new Error('附件已失效')) }) + claim('/goal ', '目标', true) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(view.getByText('附件已失效')).toBeTruthy() }) + expect(submit).not.toHaveBeenCalled() + expect(shell.snapshot.imageIds).toEqual([img]) + expect(release).not.toHaveBeenCalled() + expect(shell.snapshot.phase).toBe('claimed') + }) +}) + describe('matrix row: submitting', () => { it('locks enter, renders pending + read-only, keeps the claim snapshot on the currency', async () => { const submit = vi.fn(() => new Promise(() => {})) // never settles diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 8f72841e4f..2d8430733a 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -15,10 +15,13 @@ import { EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, SessionRuntime, } from '@deepseek-ai/dsh-client-runtime/client' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { + ClientSessionContext, CommandClaim, PickOutcome, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, +} from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.client.ts' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' @@ -33,20 +36,26 @@ afterEach(cleanup) interface FakeCommand { name: string description: string - input?: { hint: string } + input?: { hint: string; images?: boolean } } /** Decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */ -function commandSource(commands: FakeCommand[], execute: (line: string) => Promise) { +function commandSource( + commands: FakeCommand[], + execute: (line: string, images?: readonly SubmitImageAttachment[]) => Promise, +) { const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name) const leadingClaim = (desc: FakeCommand): CommandClaim => ({ token: `/${desc.name} `, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - submit: args => execute(`/${desc.name} ${args}`), + ...(desc.input?.images === true ? { images: true } : {}), + submit: (args, _actx, images) => execute(`/${desc.name} ${args}`, images), }) const executed: string[] = [] + const envelopes: SubmitEnvelope[] = [] return { executed, + envelopes, source: { trigger: '/' as const, name: 'command', @@ -68,7 +77,8 @@ function commandSource(commands: FakeCommand[], execute: (line: string) => Promi if (desc?.input === undefined) return undefined return { claim: leadingClaim(desc) } }, - matchEnter: (_session: ClientSessionContext, line: string): Promise => { + matchEnter: (_session: ClientSessionContext, line: string, _signal: AbortSignal, envelope: SubmitEnvelope): Promise => { + envelopes.push(envelope) const trimmed = line.trim() const ws = trimmed.search(/\s/) const token = ws === -1 ? trimmed : trimmed.slice(0, ws) @@ -87,8 +97,11 @@ function commandSource(commands: FakeCommand[], execute: (line: string) => Promi const COMMANDS: FakeCommand[] = [ { name: 'goal', description: '设定目标', input: { hint: '目标内容' } }, { name: 'compact', description: '压缩上下文' }, + { name: 'vision', description: '识别图片', input: { hint: '想问什么', images: true } }, ] +const PNG: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } + /** Real scope bench: SessionRuntime over one listed session + InputTriggerController + shell listeners (the hub wiring shape). */ async function scopedBench(register?: (inputTriggers: InputTriggerService) => void) { const ctx = new Context() @@ -107,7 +120,9 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const actx = sessions.scope(sessionId)! const controller = inputTriggers.sessionOf(actx) const sink = vi.fn() - const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink }) + const serialize = vi.fn((ids: readonly DraftAttachmentId[]) => Promise.resolve(ids.map(() => PNG))) + const release = vi.fn() + const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined) @@ -138,7 +153,12 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo keyboard: shell, addImages: () => null, removeImage: () => {}, - draftImages: () => [], + // Every id resolves so the bar's registry prune never drops a test image. + draftImages: ids => ids.map(id => ({ + kind: 'image' as const, id, + file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), + previewUrl: `blob:${id}`, + })), resolveSubmitMode: () => 'queue', toggleCommandMenu: (selection) => { const snapshot = shell.snapshot @@ -164,15 +184,15 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const type = (text: string): void => { fireEvent.change(textarea, { target: { value: text } }) } - return { ctx, inputTriggers, controller, shell, wiring, view, textarea, type, sink } + return { ctx, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release } } async function bench(executeImpl?: (line: string) => Promise) { const execute = vi.fn(executeImpl ?? ((line: string) => Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` }))) - const { source, executed } = commandSource(COMMANDS, execute) + const { source, executed, envelopes } = commandSource(COMMANDS, execute) const base = await scopedBench((inputTriggers) => { inputTriggers.registerSource(source) }) - return { ...base, execute, executed } + return { ...base, execute, executed, envelopes } } describe('scenario A: menu-pick /goal, type args, enter submits', () => { @@ -197,7 +217,7 @@ describe('scenario A: menu-pick /goal, type args, enter submits', () => { expect(b.shell.snapshot.phase).toBe('claimed') // Enter: submitting → command execute → commit clears. fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1') }) + await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1', []) }) await vi.waitFor(() => { expect(b.textarea.value).toBe('') }) expect(b.shell.snapshot.phase).toBe('plain') expect(b.view.getByText('已执行 /goal 发布 v1')).toBeTruthy() @@ -212,7 +232,7 @@ describe('scenario C: pasted /goal xxx + enter (menu never opened)', () => { // the caret mid-whitespace — menu stays closed; enter runs adjudication. act(() => { b.shell.setDraft('/goal 尽快发布') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布') }) + await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布', []) }) await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') }) expect(b.textarea.value).toBe('') expect(b.sink).not.toHaveBeenCalled() @@ -247,6 +267,33 @@ describe('scenario D: execute-kind /compact', () => { }) }) +describe('scenario: images ride an accepting command through the real pipeline', () => { + it('adjudication reports the image count; the claim chain serializes, submits, and consumes', async () => { + const b = await bench() + act(() => { b.shell.addImages(['img-1' as DraftAttachmentId]) }) + act(() => { b.shell.setDraft('/vision 这张图是什么') }) + fireEvent.keyDown(b.textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/vision 这张图是什么', [PNG]) }) + // The envelope the controller forwarded to matchEnter carried the count. + expect(b.envelopes).toEqual([{ images: 1 }]) + expect(b.serialize).toHaveBeenCalledWith(['img-1']) + await vi.waitFor(() => { expect(b.textarea.value).toBe('') }) + expect(b.release).toHaveBeenCalledWith(['img-1']) + expect(b.shell.snapshot.imageIds).toEqual([]) + expect(b.sink).not.toHaveBeenCalled() + }) + + it('an imageless enter adjudicates with a zero-image envelope', async () => { + const b = await bench() + act(() => { b.shell.setDraft('/goal 发布') }) + fireEvent.keyDown(b.textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布', []) }) + expect(b.envelopes).toEqual([{ images: 0 }]) + expect(b.serialize).not.toHaveBeenCalled() + expect(b.release).not.toHaveBeenCalled() + }) +}) + describe('scenario H: backspace breaks the token', () => { it('claim releases automatically; the enter after that goes through adjudication again', async () => { const b = await bench() diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index acf618fb2b..401b97d396 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -32,7 +32,7 @@ import type { ViewTab } from '../src/client/contract/views.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { const sink = vi.fn() - const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink }) + const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) return { wiring: shell, sink, shell } } diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml index 069c8e0ca7..12966cb57e 100644 --- a/packages/client/ui-input-trigger/README.i18n.yaml +++ b/packages/client/ui-input-trigger/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-input-trigger/README.md -README.md: f1858d3b506826d4e5eeb8d101b9bc216c1c0615 -README.zh.md: 655b3f0458062ae7b578375415a1e28f6d3171dd +README.md: 917a0be02d48260704be8dc2c2f70504138c1957 +README.zh.md: cf33c51c40edd53a492416b9654cb9e69680aebd diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md index f1858d3b50..917a0be02d 100644 --- a/packages/client/ui-input-trigger/README.md +++ b/packages/client/ui-input-trigger/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.inputTriggers` owns the source roster and resolves one `InputTriggerController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.inputTriggers` owns the source roster and resolves one `InputTriggerController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Enter adjudication also carries a `SubmitEnvelope` (the composer's image-attachment count) so a source can refuse a submission it cannot consume whole; a `CommandClaim` declares `images: true` when its command accepts composer images, and its `submit` then receives the serialized payloads as a third argument. Layering: `src/core/` is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract; changes require main-thread arbitration. diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md index 655b3f0458..cf33c51c40 100644 --- a/packages/client/ui-input-trigger/README.zh.md +++ b/packages/client/ui-input-trigger/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.inputTriggers` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `InputTriggerController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 创建时 roster 中已有的 source 会在 controller 构造期间预热,晚于此注册的 source 由注册动作本身预热进每个仍存续的 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.inputTriggers` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `InputTriggerController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 创建时 roster 中已有的 source 会在 controller 构造期间预热,晚于此注册的 source 由注册动作本身预热进每个仍存续的 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。回车裁决还携带 `SubmitEnvelope`(composer 的图片附件数量),使 source 能拒绝它无法整体消费的提交;命令接受 composer 图片时,`CommandClaim` 声明 `images: true`,其 `submit` 随之以第三个参数收到序列化后的图片载荷。 分层:`src/core/` 是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。 diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 5a6bcb7b21..8dd49fd541 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -13,7 +13,7 @@ import { detectTrigger } from '../core/detect.ts' import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts' import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts' import type { - ArbitrateKey, ArbitrateOutcome, ClientSessionContext, PickOutcome, InputTriggerSource, TriggerChar, TriggerGuard, + ArbitrateKey, ArbitrateOutcome, ClientSessionContext, PickOutcome, InputTriggerSource, SubmitEnvelope, TriggerChar, TriggerGuard, } from '../types.ts' /** Roster access the controller borrows from the root service (registration order preserved). */ @@ -248,17 +248,19 @@ export class InputTriggerController { * input machine applies it inside the same submit attempt — no event). * @param line - trimmed draft; the leading char selects the trigger roster. * @param signal - attempt-scoped abort from the input machine. + * @param envelope - non-text submission state accompanying the draft. * @returns the winning outcome or undefined (default sink). Rejects when a - * polled source's warmup fails — the caller must not silently downgrade. + * polled source's warmup fails or the winning source refuses the envelope — + * the caller must not silently downgrade. */ - async adjudicate(line: string, signal: AbortSignal): Promise { + async adjudicate(line: string, signal: AbortSignal, envelope: SubmitEnvelope): Promise { const projection = this.project() for (const src of this.deps.roster.all()) { if (signal.aborted) { throw signal.reason instanceof Error ? signal.reason : new Error('slash adjudication aborted') } if (src.matchEnter === undefined || !line.startsWith(src.trigger)) continue - const outcome = await src.matchEnter(projection, line, signal) + const outcome = await src.matchEnter(projection, line, signal, envelope) if (outcome !== undefined) return outcome } return undefined diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index ea5a63063f..0e7f1af099 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -21,8 +21,8 @@ export type { MenuKey } from './locales.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext, CommandClaim, ConsumeTokenRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec, - ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SubmitOutcome, TokenSpan, - TriggerChar, TriggerGuard, TriggerPosition, + ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SubmitEnvelope, + SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition, } from '../types.ts' export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' export type { InputTriggerServiceContract } from './contract.ts' diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 85cbb87f5a..bbbbeefd88 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -44,6 +44,16 @@ export interface TokenSpan { readonly draftRev: number } +/** Base64-encoded composer image accompanying one claimed submit transaction. */ +export interface SubmitImageAttachment { + /** Declared media type; the host verifies it against the decoded bytes. */ + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + /** Canonical base64 encoding of the image bytes. */ + readonly data: string + /** Optional display name; never interpreted as a path. */ + readonly name?: string +} + /** * Command-mode entry credential. Pure data + a closure method — no class, no * cross-package runtime value (client bundle purity). @@ -53,8 +63,18 @@ export interface CommandClaim { readonly token: string /** Ghost-text hint rendered while the claim's args are blank. */ readonly hint?: string - /** Enter transaction, supplied by the source as a closure. */ - submit(args: string, actx: ClientContext): Promise + /** + * Whether composer image attachments may accompany this command's submit. + * Absent = the composer refuses to submit while images are attached, keeping + * the draft and the images in place behind a visible notice. + */ + readonly images?: boolean + /** + * Enter transaction, supplied by the source as a closure. + * @param images - serialized composer images accompanying the submission; + * the composer passes them only when {@link CommandClaim.images} is true. + */ + submit(args: string, actx: ClientContext, images: readonly SubmitImageAttachment[]): Promise } /** @@ -94,6 +114,16 @@ export type PickOutcome = | 'handled' | undefined +/** + * Non-text composer submission state visible to enter adjudication. The + * composer owns the actual attachment payloads; adjudication only needs their + * presence to accept or refuse a whole submission. + */ +export interface SubmitEnvelope { + /** Number of image attachments accompanying the draft. */ + readonly images: number +} + /** Candidate request passed to a source. The signal is superseded on query change / menu close. */ export interface CandidateRequest { readonly query: string @@ -151,9 +181,17 @@ export interface InputTriggerSource { * reject on warmup failure. `line` is the full trimmed draft: the source * parses it and applies its own kind policy — args-tolerant kinds claim * with trailing text present, bare-token-only kinds answer undefined - * unless the line is exactly the token. + * unless the line is exactly the token. `envelope` describes the rest of + * the composer submission; a source that would consume the line but cannot + * consume the whole envelope throws to surface the refusal and leave the + * submission intact. */ - matchEnter?(session: ClientSessionContext, line: string, signal: AbortSignal): Promise + matchEnter?( + session: ClientSessionContext, + line: string, + signal: AbortSignal, + envelope: SubmitEnvelope, + ): Promise /** * Scope-birth prewarm hook (fire-and-forget): the per-session controller * calls it once when the session scope comes alive so sources can fetch diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 335f5a6aed..6bf3c02017 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -796,7 +796,7 @@ describe('adjudicate', () => { return Promise.resolve('handled') }), ]) - const result = await controller.adjudicate('/goal make it fast', new AbortController().signal) + const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { images: 0 }) expect(result).toEqual({ claim }) expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast']) }) @@ -807,16 +807,34 @@ describe('adjudicate', () => { enterSource('@', 'subagent', atHook), enterSource('/', 'command', () => Promise.resolve(undefined)), ]) - await expect(controller.adjudicate('/xyz', new AbortController().signal)).resolves.toBeUndefined() + await expect(controller.adjudicate('/xyz', new AbortController().signal, { images: 0 })).resolves.toBeUndefined() expect(atHook).not.toHaveBeenCalled() }) + it('forwards the caller envelope to every polled matchEnter unchanged', async () => { + const envelopes: unknown[] = [] + const { controller } = controllerBench([ + enterSource('/', 'first', (_session, _line, _signal, envelope) => { + envelopes.push(envelope) + return Promise.resolve(undefined) + }), + enterSource('/', 'second', (_session, _line, _signal, envelope) => { + envelopes.push(envelope) + return Promise.resolve('handled') + }), + ]) + const envelope = { images: 2 } + await controller.adjudicate('/goal', new AbortController().signal, envelope) + expect(envelopes).toEqual([envelope, envelope]) + expect(envelopes[0]).toBe(envelope) + }) + it('a rejecting source rejects the whole adjudication', async () => { const { controller } = controllerBench([ enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))), enterSource('/', 'late', () => Promise.resolve('handled')), ]) - await expect(controller.adjudicate('/goal x', new AbortController().signal)) + await expect(controller.adjudicate('/goal x', new AbortController().signal, { images: 0 })) .rejects.toThrow('warmup failed') }) @@ -825,7 +843,7 @@ describe('adjudicate', () => { const { controller } = controllerBench([enterSource('/', 'command', hook)]) const abort = new AbortController() abort.abort(new Error('attempt released')) - await expect(controller.adjudicate('/goal', abort.signal)).rejects.toThrow('attempt released') + await expect(controller.adjudicate('/goal', abort.signal, { images: 0 })).rejects.toThrow('attempt released') expect(hook).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 4fe7bd3e37..a19a028bb6 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -55,7 +55,7 @@ export function apply(ctx: ClientContext): void { inject: (sessionId: SessionId): PlanChipInjected => ({ // Failure strings stay English (error-surface policy: not localized). exitPlanMode: async () => { - const result = await ctx.remote.commands.execute(sessionId, '/plan off') + const result = await ctx.remote.commands.execute(sessionId, '/plan off', []) if (!result.ok) return `${result.error.message} (${result.error.code})` if (result.value === undefined) return 'unknown command: /plan off' return null diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index 1bbb2d633b..347f38d552 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -68,7 +68,7 @@ describe('ui-plan browser apply', () => { const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) await expect(injected.exitPlanMode()).resolves.toBeNull() - expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off') + expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off', []) // Business failure folds to the composer-visible line: the generated method // reports the RPC failure in its error branch. diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index 422e6eaba7..97229590b7 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -110,7 +110,7 @@ async function run( suffix = '', controller = new AbortController(), ): Promise>>> { - const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, controller.signal) + const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, [], controller.signal) if (execution === undefined) throw new Error('compact command was not registered') return execution } diff --git a/packages/compaction/command-compact/tests/loader-composition.spec.ts b/packages/compaction/command-compact/tests/loader-composition.spec.ts index bb1d04a872..95358b0036 100644 --- a/packages/compaction/command-compact/tests/loader-composition.spec.ts +++ b/packages/compaction/command-compact/tests/loader-composition.spec.ts @@ -123,7 +123,7 @@ describe('command-compact real Loader composition', () => { name: 'compact', description: 'Compact older conversation history', }) - const execution = await context.commands.execute(agent, '/compact', new AbortController().signal) + const execution = await context.commands.execute(agent, '/compact', [], new AbortController().signal) if (execution === undefined) throw new Error('Loader composition did not resolve /compact') expect(execution.result).toEqual({ kind: 'success', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7fb624d21f..def62d20f0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -462,9 +462,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the scoped shadow or global definition.', }, { - signature: '@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise', - description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.', - parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], + signature: '@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise', + description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nImage admission is enforced here, not in the composer: images sent to a command that does not declare `input.images`, an absent attachment store, and an exceeded attachment limit each settle as an error result before the handler runs, and a rejected batch publishes no durable object.', + parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'images', description: 'base64-encoded composer images accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], returns: 'the settled execution (result + lifecycle pairing id), or `undefined` when syntax or name does not resolve.', }, ], @@ -2795,11 +2795,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandInputDescriptor', - declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n}', + declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n readonly images?: boolean;\n}', }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly attachments: readonly ImageBlock[];\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', @@ -3025,6 +3025,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EditGoalRequest', declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', }, + { + name: 'EncodedImageAttachment', + declaration: 'export interface EncodedImageAttachment {\n mediaType: ImageMediaType;\n data: string;\n name?: string;\n}', + }, { name: 'EpochHeader', declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n}', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 9105e8d5f3..3c567df332 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -85,6 +85,7 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s const settled = await test.ctx.commands.execute( test.agent, `/feedback${suffix}`, + [], new AbortController().signal, ) if (settled === undefined) throw new Error('feedback command was not registered') @@ -168,8 +169,8 @@ describe('/feedback human command', () => { const signal = new AbortController().signal // Command adapters may dispatch concurrent requests without awaiting one another. const settled = await Promise.all([ - test.ctx.commands.execute(test.agent, '/feedback first', signal), - test.ctx.commands.execute(test.agent, '/feedback second', signal), + test.ctx.commands.execute(test.agent, '/feedback first', [], signal), + test.ctx.commands.execute(test.agent, '/feedback second', [], signal), ]) expect(settled.map(item => item?.result)).toEqual([ { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}. Session sharing is not configured.` }, @@ -238,7 +239,7 @@ describe('/feedback human command', () => { const test = await harness() const controller = new AbortController() controller.abort(new Error('user cancelled the command')) - await expect(test.ctx.commands.execute(test.agent, '/feedback too late', controller.signal)) + await expect(test.ctx.commands.execute(test.agent, '/feedback too late', [], controller.signal)) .rejects.toThrow('user cancelled the command') expect(test.session.events).toEqual([]) }) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 7ff8c2e4cd..d133d86830 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -89,13 +89,13 @@ describe('/feedback real Loader composition through cordis.yml', () => { // Discoverable through the composed registry, as a UI adapter finds it. expect(context.commands.list(owner).map(command => command.name)).toContain('feedback') - const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal) + const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', [], signal) const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } }) expect(accepted?.result).toEqual({ kind: 'success', text: `Feedback recorded for session feedback-loader-agent\nAnonymous user: ${userId}. Session sharing is not configured.`, }) - const rejected = await context.commands.execute(owner, '/feedback', signal) + const rejected = await context.commands.execute(owner, '/feedback', [], signal) expect(rejected?.result).toEqual({ kind: 'error', text: 'Feedback text is required. Usage: /feedback ', diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml index f98588640f..ac6f7b71aa 100644 --- a/packages/goal/command-goal/README.i18n.yaml +++ b/packages/goal/command-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/goal/command-goal/README.md -README.md: ee32ea9e5b90d79f9912d7817d6f14b2227ece64 -README.zh.md: ee40cd4219c45b6d9650036fda21c56599be6abd +README.md: 483f756517f511ec506868b008b88e99b2477dc0 +README.zh.md: cd3c396ad446721de4cc091d511b615149ab2b30 diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index ee32ea9e5b..483f756517 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -17,6 +17,8 @@ Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin r 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. +The command declares `input.images`, so composer image attachments may accompany an invocation. Attachments only accompany an objective: on a successful create or edit the producer submits one user followup carrying the admitted image blocks plus the fixed text `Reference images for the goal objective.`, so later goal rounds read them from ordinary session history without the goal domain storing attachment state. Every other sub-command, and any refused create or edit, returns a direct error and submits nothing, so the dispatching composer keeps the images. + Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; `dsh-goal` persists every accepted mutation through its own durable `goal/change` event. ## Composition @@ -40,11 +42,11 @@ The shipped `dsh` base enables the persisted-goal stack and this command; the We #### What the model sees -The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. +The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. When a create or edit carries image attachments, the model sees one ordinary user message: the image blocks followed by the text `Reference images for the goal objective.`; it precedes the next goal round in session history. #### Token effect -Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. +Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. An objective's image attachments add one user message billed like any image prompt. #### KV Cache effect diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md index ee40cd4219..cd3c396ad4 100644 --- a/packages/goal/command-goal/README.zh.md +++ b/packages/goal/command-goal/README.zh.md @@ -17,6 +17,8 @@ 只有控制词占据完整输入时才不区分大小写。其他任何非空后缀都属于目标,因此 `/goal pause after verification` 会创建该字面目标。goal 领域会去除目标首尾空白并进行验证。由于通用命令平面没有模态编辑器或确认原语,`edit` 会内联接收替换内容;若试图替换未完成的 goal,则直接返回错误,提示用户执行 edit 或 clear。 +该命令声明了 `input.images`,因此 composer 图片附件可以随调用一起提交。附件只随目标本身:create 或 edit 成功时,生产方提交一条用户 followup 消息,内容为已准入的图片块加固定文本 `Reference images for the goal objective.`,后续 Goal Round 从普通会话历史中读取它们,goal 领域不存储附件状态。其他任何子命令、以及被拒绝的 create 或 edit,都直接返回错误且不提交任何消息,分发方 composer 保留图片。 + 可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过自有的持久 `goal/change` 事件记录每项已接受变更。 ## 组合 @@ -40,11 +42,11 @@ #### 模型看到的内容 -斜杠输入、变更以及直接状态/错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续继续执行提示词中暴露结果状态。呈现文本绝不会记录到日志中。 +斜杠输入、变更以及直接状态/错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续继续执行提示词中暴露结果状态。呈现文本绝不会记录到日志中。当 create 或 edit 携带图片附件时,模型会看到一条普通用户消息:图片块后跟文本 `Reference images for the goal objective.`,在会话历史中位于下一个 Goal Round 之前。 #### Token 影响 -读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。 +读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。目标携带的图片附件会增加一条用户消息,其计费与任何图片提示词相同。 #### KV Cache 影响 diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index 38d18e2529..e1b6e79eed 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -7,6 +7,7 @@ import type { Context } from '@deepseek-ai/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' +import { createUserMessage } from '@deepseek-ai/dsh-llm' export const name = 'command-goal' export const inject = ['commands', 'goals'] @@ -106,9 +107,29 @@ function missingGoal(action: string): CommandResult { } } +/** + * Submit the invocation's admitted composer images as one model-visible user + * message ahead of the goal's next round. The images precede a fixed text + * block naming their role, so a later goal round reads them from ordinary + * session history without the goal domain storing attachment state. + */ +function submitObjectiveAttachments(invocation: CommandInvocation): void { + if (invocation.attachments.length === 0) return + invocation.agent.followup(createUserMessage({ + content: [...invocation.attachments, { type: 'text', text: 'Reference images for the goal objective.' }], + source: { kind: 'user' }, + })) +} + /** Execute one parsed human command through the domain that owns persistence. */ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): CommandResult { const command = parseGoalCommand(invocation.rawInput) + if (invocation.attachments.length > 0 && command.kind !== 'create' && command.kind !== 'edit') { + return { + kind: 'error', + text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + } + } try { const current = ctx.goals.get(invocation.agent) switch (command.kind) { @@ -118,23 +139,28 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman : renderGoal('Goal', current) case 'invalid-edit': return { kind: 'error', text: `Goal editing requires a replacement objective.\n${USAGE}` } - case 'create': + case 'create': { if (current !== undefined && current.phase !== 'complete') { return { kind: 'error', text: `A goal is already ${phaseLabel(current.phase)}. Use /goal edit to change it or /goal clear before replacing it.`, } } - return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective })) - case 'edit': + const created = ctx.goals.create(invocation.agent, { objective: command.objective }) + submitObjectiveAttachments(invocation) + return renderGoal('Goal created', created) + } + case 'edit': { if (current === undefined) return missingGoal('edit') if (current.phase === 'complete') { - return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective })) + const replaced = ctx.goals.create(invocation.agent, { objective: command.objective }) + submitObjectiveAttachments(invocation) + return renderGoal('Goal created', replaced) } - return renderGoal( - 'Goal updated', - ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }), - ) + const edited = ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }) + submitObjectiveAttachments(invocation) + return renderGoal('Goal updated', edited) + } case 'pause': if (current === undefined) return missingGoal('pause') return renderGoal('Goal paused', ctx.goals.pause(invocation.agent, goalRef(current))) @@ -164,7 +190,7 @@ export function apply(ctx: Context): void { ctx.commands.register({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]' }, + input: { hint: '[|clear|edit |pause|resume]', images: true }, handler: invocation => executeGoalCommand(ctx, invocation), }) } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index cae48f9b92..4b71fc3e9d 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -73,6 +73,7 @@ async function run(test: Harness, suffix = ''): Promise { expect(test.ctx.commands.list(test.agent)).toContainEqual({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]' }, + input: { hint: '[|clear|edit |pause|resume]', images: true }, }) expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined() @@ -232,3 +233,93 @@ describe('/goal human command', () => { await expect(run(test)).rejects.toThrow('unexpected failure') }) }) + +describe('/goal image attachments', () => { + const PNG = 'AAAA' + + /** Wire the fake store the executor admits through (once per harness). */ + function provideStore(test: Harness): void { + let saved = 0 + test.ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 1024, maxImagesPerMessage: 4, maxMessageImageBytes: 1024, + maxImagePixels: 1_000_000, mediaTypes: ['image/png'], + }, + validateImage: () => Promise.resolve(), + saveImage: (input: { mediaType: string; name?: string }) => { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, + }) + }, + }) + } + + /** Run /goal with `count` composer images through the executor boundary. */ + async function runWithImages(test: Harness, suffix: string, count: number) { + const images = Array.from({ length: count }, (_, index) => ({ + mediaType: 'image/png' as const, data: PNG, name: `ref-${index + 1}.png`, + })) + const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, images, new AbortController().signal) + if (execution === undefined) throw new Error('goal command was not registered') + return execution.result + } + + it('submits one user followup carrying the admitted images ahead of the round prompt', async () => { + const test = await harness() + provideStore(test) + const followup = vi.fn() + ;(test.agent as unknown as { followup: typeof followup }).followup = followup + const result = await runWithImages(test, ' rebuild the cathedral', 2) + expect(result.kind).toBe('success') + expect(followup).toHaveBeenCalledTimes(1) + const message = followup.mock.calls[0]?.[0] as { + content: ReadonlyArray> + source: { kind: string } + } + expect(message.source).toEqual({ kind: 'user' }) + expect(message.content.map(block => block.type)).toEqual(['image', 'image', 'text']) + expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference images for the goal objective.' }) + expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref-1.png') + }) + + it('accompanies an edit and a post-complete recreate the same way', async () => { + const test = await harness() + provideStore(test) + const followup = vi.fn() + ;(test.agent as unknown as { followup: typeof followup }).followup = followup + test.ctx.goals.create(test.agent, { objective: 'initial objective' }) + const result = await runWithImages(test, ' edit refined objective', 1) + expect(result.kind).toBe('success') + expect(followup).toHaveBeenCalledTimes(1) + }) + + it('rejects attachments on sub-commands that cannot use them, leaving the domain untouched', async () => { + const test = await harness() + provideStore(test) + const followup = vi.fn() + ;(test.agent as unknown as { followup: typeof followup }).followup = followup + test.ctx.goals.create(test.agent, { objective: 'active objective' }) + for (const suffix of [' pause', '', ' clear']) { + const result = await runWithImages(test, suffix, 1) + expect(result).toEqual({ + kind: 'error', + text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + }) + } + expect(followup).not.toHaveBeenCalled() + expect(test.ctx.goals.get(test.agent)?.phase).toBe('active') + }) + + it('does not submit attachments when goal creation is refused', async () => { + const test = await harness() + provideStore(test) + const followup = vi.fn() + ;(test.agent as unknown as { followup: typeof followup }).followup = followup + test.ctx.goals.create(test.agent, { objective: 'existing objective' }) + const result = await runWithImages(test, ' replacement objective', 1) + expect(result.kind).toBe('error') + expect(followup).not.toHaveBeenCalled() + }) +}) diff --git a/packages/goal/command-goal/tsconfig.json b/packages/goal/command-goal/tsconfig.json index 0be94e6ef3..bfd4de65c5 100644 --- a/packages/goal/command-goal/tsconfig.json +++ b/packages/goal/command-goal/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../interaction/commands" }, + { + "path": "../../llm/llm" + }, { "path": "../goal" }, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 4ae8afb82e..ab8f6aa127 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -10,7 +10,7 @@ import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' @@ -123,53 +123,17 @@ export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 /** Conversation message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -/** Decode the browser payload while rejecting non-canonical base64 forms. */ -function decodeBase64(data: string): Uint8Array { - const decoded = Buffer.from(data, 'base64') - if (data.length === 0 || decoded.toString('base64') !== data) { - throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') - } - return new Uint8Array(decoded) -} - /** Validate one prompt as a batch before publishing any durable image object. */ async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise { if (content.every(part => part.type === 'text')) { return content.map(part => ({ type: 'text', text: part.text })) } - const limits = ctx.attachments.imageLimits - if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) { - throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') - } - const prepared = content.map(part => part.type === 'text' - ? part - : { part, data: decodeBase64(part.data) }) - const images = prepared.filter((part): part is Extract => 'data' in part) - const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) - if (totalBytes > limits.maxMessageImageBytes) { - throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') - } - for (const image of images) { - await ctx.attachments.validateImage({ - data: image.data, - mediaType: image.part.mediaType, - ...image.part.name === undefined ? {} : { name: image.part.name }, - }) - } - const blocks: ContentBlock[] = [] - for (const item of prepared) { - if (!('data' in item)) { - blocks.push({ type: 'text', text: item.text }) - continue - } - const attachment = await ctx.attachments.saveImage({ - data: item.data, - mediaType: item.part.mediaType, - ...item.part.name === undefined ? {} : { name: item.part.name }, - }) - blocks.push({ type: 'image', attachment }) - } - return blocks + const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image')) + let next = 0 + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + // admitEncodedImages returns one reference per image part in order. + : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) } /** Search durable content for an image reference, including nested tool results. */ diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml index 9a53413b85..fa8ce400f3 100644 --- a/packages/interaction/commands/README.i18n.yaml +++ b/packages/interaction/commands/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/commands/README.md -README.md: e1d7e5f4f626d062a16840bce6354ee3aac921c9 -README.zh.md: 6ce3ca9016e26537003ba85e0b2217bd37448fc5 +README.md: 4a4cb2a70b56ba1a18e9f4719541a50a9683510c +README.zh.md: f89ccd1a5cc9189b2810026481d4c57855481111 diff --git a/packages/interaction/commands/README.md b/packages/interaction/commands/README.md index e1d7e5f4f6..4a4cb2a70b 100644 --- a/packages/interaction/commands/README.md +++ b/packages/interaction/commands/README.md @@ -6,9 +6,9 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl ## Service contract -`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. +`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input descriptor (`hint` plus an `images` flag declaring whether composer image attachments may accompany an invocation), optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. -`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. +`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing (the descriptor carries `input.images` so composers can refuse image submissions to non-declaring commands before dispatch). `find(agent, name)` returns the corresponding definition. `execute(agent, line, images, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. `images` carries the submission's base64-encoded composer images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`); the executor enforces the declaration — images sent to a non-declaring command, an absent `attachments` store, or an exceeded batch limit each settle as an error result before the handler runs, and a rejected batch publishes no durable object. An admitted batch is committed through `admitEncodedImages` and handed to the handler as frozen ordered `ImageBlock`s on `invocation.attachments`; the handler owns their model-visible use and returns an error when its grammar cannot use them, so the dispatching composer keeps the originals. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. `parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. @@ -24,7 +24,7 @@ The shipped `dsh` base mounts this service and the Web client dispatches through #### What the model sees -The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message in `/plan [message]` after selecting plan mode. +The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message in `/plan [message]` after selecting plan mode. Image attachments follow the same rule: the executor only admits them into durable attachment objects, and a declaring producer decides whether and how they become model-visible message content. #### Token effect diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md index 6ce3ca9016..f89ccd1a5c 100644 --- a/packages/interaction/commands/README.zh.md +++ b/packages/interaction/commands/README.zh.md @@ -6,9 +6,9 @@ ## 服务约定 -`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 +`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入描述符(`hint`,以及声明调用是否可携带 composer 图片附件的 `images` 标志)、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 +`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符(描述符携带 `input.images`,使 composer 能在分发前就拒绝把图片提交给未声明的命令)。`find(agent, name)` 返回相应定义。`execute(agent, line, images, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。`images` 携带本次提交的 base64 编码 composer 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`);执行器负责声明的强制执行:把图片发给未声明的命令、`attachments` 存储缺失、或批量超出限制,都会在处理器运行前以错误结果结算,被拒绝的批量不会发布任何持久化对象。通过准入的批量经 `admitEncodedImages` 提交,并以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器;处理器负责它们的模型可见用途,当其语法无法使用这些图片时返回错误,使分发方 composer 保留原件。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 `parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。 @@ -24,7 +24,7 @@ #### 模型看到的内容 -注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。 +注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。 #### Token 影响 diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 59322d8de9..925065bbaf 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -54,8 +54,10 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", @@ -66,8 +68,10 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 4e179435bb..df812d6334 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -5,6 +5,9 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' +import type { ImageBlock } from '@deepseek-ai/dsh-llm' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' @@ -24,6 +27,9 @@ export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u +/** Shared frozen attachments value for image-free invocations. */ +const NO_ATTACHMENTS: readonly ImageBlock[] = Object.freeze([]) + /** Invocation passed to one registered command handler. */ export interface CommandInvocation { /** Pairing id already written to this invocation's `command/run` event. */ @@ -32,6 +38,14 @@ export interface CommandInvocation { readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string + /** + * Durably admitted image blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.images`. The handler + * owns their model-visible use — the registry never schedules them itself — + * and a handler whose grammar cannot use them in this invocation returns an + * error so the dispatching composer retains the originals. + */ + readonly attachments: readonly ImageBlock[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -171,7 +185,13 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { if (rawInput.hint.trim().length === 0) { throw new TypeError(`command "${definition.name}" input hint must not be empty`) } - input = Object.freeze({ hint: rawInput.hint }) + if ('images' in rawInput && rawInput.images !== undefined && typeof rawInput.images !== 'boolean') { + throw new TypeError(`command "${definition.name}" input images flag must be a boolean`) + } + input = Object.freeze({ + hint: rawInput.hint, + ...('images' in rawInput && rawInput.images === true) ? { images: true } : {}, + }) } const normalized = Object.freeze({ name: definition.name, @@ -287,8 +307,15 @@ export class CommandRuntime extends TypertRemoteService { * handler-failure path is contained so the handler's own error stays the * reported failure. * + * Image admission is enforced here, not in the composer: images sent to a + * command that does not declare `input.images`, an absent attachment store, + * and an exceeded attachment limit each settle as an error result before + * the handler runs, and a rejected batch publishes no durable object. + * * @param agent - exact receiving agent. * @param line - complete slash-command line. + * @param images - base64-encoded composer images accompanying the line, in + * submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. @@ -297,6 +324,7 @@ export class CommandRuntime extends TypertRemoteService { async execute( agent: Agent, line: string, + images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise { const parsed = parseCommand(line) @@ -311,30 +339,58 @@ export class CommandRuntime extends TypertRemoteService { ...command.definition.recordInput === false ? {} : { args: parsed.rawInput }, source: { kind: 'user' }, }) - const invocation = Object.freeze({ commandId, agent, rawInput: parsed.rawInput, signal }) + const settle = (result: CommandResult): CommandExecution => { + this.appendLifecycle(agent.session, 'command/done', { + commandId, kind: result.kind, + ...result.text === undefined ? {} : { text: result.text }, + ...result.kind === 'success' && result.sourceEventSeq !== undefined + ? { sourceEventSeq: result.sourceEventSeq } + : {}, + }) + return Object.freeze({ commandId, result: Object.freeze(result) }) + } + let attachments: readonly ImageBlock[] = NO_ATTACHMENTS + if (images.length > 0) { + if (command.definition.input?.images !== true) { + return settle({ kind: 'error', text: `/${parsed.name} does not accept image attachments` }) + } + const store = this.ctx.get('attachments') + if (store === undefined) { + return settle({ kind: 'error', text: `/${parsed.name}: image attachments are unavailable because no attachment store is composed` }) + } + try { + const refs = await admitEncodedImages(store, images) + attachments = Object.freeze(refs.map(ref => Object.freeze({ type: 'image' as const, attachment: ref }))) + } catch (error: unknown) { + if (error instanceof AttachmentError) { + return settle({ kind: 'error', text: error.message }) + } + this.settleThrown(agent.session, parsed.name, commandId, error) + throw error + } + } + const invocation = Object.freeze({ commandId, agent, rawInput: parsed.rawInput, attachments, signal }) let result: CommandResult try { const output = command.definition.handler(invocation) result = normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal)) } catch (error: unknown) { - try { - this.appendLifecycle(agent.session, 'command/done', { - commandId, kind: 'error', - text: error instanceof Error ? error.message : renderThrown(error), - }) - } catch (appendError: unknown) { - this.ctx.logger.warn(`command "${parsed.name}": command/done append failed: ${renderThrown(appendError)}`) - } + this.settleThrown(agent.session, parsed.name, commandId, error) throw error } - this.appendLifecycle(agent.session, 'command/done', { - commandId, kind: result.kind, - ...result.text === undefined ? {} : { text: result.text }, - ...result.kind === 'success' && result.sourceEventSeq !== undefined - ? { sourceEventSeq: result.sourceEventSeq } - : {}, - }) - return Object.freeze({ commandId, result }) + return settle(result) + } + + /** Contained `command/done` error append for a thrown handler or admission failure. */ + private settleThrown(session: Session, command: string, commandId: CommandId, error: unknown): void { + try { + this.appendLifecycle(session, 'command/done', { + commandId, kind: 'error', + text: error instanceof Error ? error.message : renderThrown(error), + }) + } catch (appendError: unknown) { + this.ctx.logger.warn(`command "${command}": command/done append failed: ${renderThrown(appendError)}`) + } } /** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */ diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 32f1dbcc43..f8e375774d 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -13,6 +13,14 @@ import type { CommandId } from './brand.ts' export interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string + /** + * Whether composer image attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying images and capable + * composers refuse the submission before dispatch. A declaring command's + * handler receives the admitted durable blocks and owns every further + * grammar decision, including rejecting sub-commands that cannot use them. + */ + readonly images?: boolean } /** Expected command outcome rendered directly by the dispatching UI. */ diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index c00f938cac..d3ac6e8bff 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -96,11 +96,11 @@ describe('CommandRuntime', () => { expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared']) expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined() expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared']) - expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result) + expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result) .toEqual({ kind: 'success', text: 'scoped' }) await scope.dispose() - expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result.text).toBe('global') + expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result.text).toBe('global') }) it('removes a registration when its contributing plugin fiber is disposed', async () => { @@ -176,7 +176,7 @@ describe('CommandRuntime', () => { ctx.commands.register({ name: 'run', description: 'Run it', handler: seen }) const controller = new AbortController() - const execution = await ctx.commands.execute(agent, '/run untouched ', controller.signal) + const execution = await ctx.commands.execute(agent, '/run untouched ', [], controller.signal) expect(execution?.result).toEqual({ kind: 'success', text: 'ok' }) expect(execution?.commandId).toBeTruthy() @@ -187,8 +187,8 @@ describe('CommandRuntime', () => { rawInput: ' untouched ', signal: controller.signal, })) - await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined() - await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, 'run', [], controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, '/missing', [], controller.signal)).resolves.toBeUndefined() }) it('stops awaiting an aborted handler and handles an already-aborted signal', async () => { @@ -201,18 +201,18 @@ describe('CommandRuntime', () => { handler: () => new Promise((resolve) => { release = resolve }), }) const running = new AbortController() - const promise = ctx.commands.execute(agent, '/wait', running.signal) + const promise = ctx.commands.execute(agent, '/wait', [], running.signal) running.abort('operator cancelled command') await expect(promise).rejects.toThrow('operator cancelled command') release({ kind: 'success', text: 'late' }) const already = new AbortController() already.abort(new Error('already gone')) - await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone') + await expect(ctx.commands.execute(agent, '/wait', [], already.signal)).rejects.toThrow('already gone') const defaultReason = new AbortController() defaultReason.abort({ source: 'test' }) - await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted') + await expect(ctx.commands.execute(agent, '/wait', [], defaultReason.signal)).rejects.toThrow('command aborted') }) it('propagates an asynchronously rejected handler', async () => { @@ -223,7 +223,7 @@ describe('CommandRuntime', () => { description: 'Reject', handler: () => Promise.reject(new Error('handler rejected')), }) - await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject', [], new AbortController().signal)) .rejects.toThrow('handler rejected') ctx.commands.register({ @@ -232,7 +232,7 @@ describe('CommandRuntime', () => { // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise untyped plugin normalization handler: () => Promise.reject('not an Error'), }) - await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject-value', [], new AbortController().signal)) .rejects.toThrow('command handler rejected with a non-Error value: not an Error') const hostile = { toString(): string { throw new Error('cannot render') } } @@ -242,7 +242,7 @@ describe('CommandRuntime', () => { // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise hostile plugin normalization handler: () => Promise.reject(hostile), }) - await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject-hostile', [], new AbortController().signal)) .rejects.toMatchObject({ message: 'command handler rejected with a non-Error value: ', cause: hostile, @@ -261,7 +261,7 @@ describe('CommandRuntime', () => { return { kind: 'success' } }, }) - await expect(ctx.commands.execute(agent, '/self-abort', controller.signal)) + await expect(ctx.commands.execute(agent, '/self-abort', [], controller.signal)) .rejects.toThrow('aborted in handler') }) @@ -273,7 +273,7 @@ describe('CommandRuntime', () => { description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }), }) - const execution = await ctx.commands.execute(agent, '/denied', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/denied', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'error', text: 'not now' }) expect(Object.isFrozen(execution?.result)).toBe(true) @@ -282,7 +282,7 @@ describe('CommandRuntime', () => { description: 'No output', handler: () => ({ kind: 'success' }), }) - const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal) + const silent = await ctx.commands.execute(agent, '/silent', [], new AbortController().signal) expect(silent?.result).toEqual({ kind: 'success' }) expect(Object.isFrozen(silent?.result)).toBe(true) }) @@ -302,7 +302,7 @@ describe('CommandRuntime', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('deploy', 'deployed')) - const execution = await ctx.commands.execute(agent, '/deploy now', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/deploy now', [], new AbortController().signal) const lifecycle = lifecycleOf(agent) expect(lifecycle).toMatchObject([ @@ -330,7 +330,7 @@ describe('CommandRuntime', () => { handler: () => ({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }), }) - const execution = await ctx.commands.execute(agent, '/linked', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/linked', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }) expect(lifecycleOf(agent)).toMatchObject([ @@ -350,7 +350,7 @@ describe('CommandRuntime', () => { handler: seen, }) - await ctx.commands.execute(agent, '/private keep this once', new AbortController().signal) + await ctx.commands.execute(agent, '/private keep this once', [], new AbortController().signal) expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' })) const run = agent.session.events.find(event => event.type === 'command/run') @@ -363,8 +363,8 @@ describe('CommandRuntime', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('first')) ctx.commands.register(command('second')) - await ctx.commands.execute(agent, '/first', new AbortController().signal) - await ctx.commands.execute(agent, '/second', new AbortController().signal) + await ctx.commands.execute(agent, '/first', [], new AbortController().signal) + await ctx.commands.execute(agent, '/second', [], new AbortController().signal) const ids = lifecycleOf(agent) .filter(event => event.type === 'command/run') .map(event => (event.data as { commandId: string }).commandId) @@ -375,7 +375,7 @@ describe('CommandRuntime', () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) }) - await ctx.commands.execute(agent, '/denied', new AbortController().signal) + await ctx.commands.execute(agent, '/denied', [], new AbortController().signal) expect(lifecycleOf(agent)).toMatchObject([ { type: 'command/run', data: { name: 'denied' } }, { type: 'command/done', data: { kind: 'error', text: 'not now' } }, @@ -390,7 +390,7 @@ describe('CommandRuntime', () => { description: 'Throw', handler: () => { throw new Error('handler exploded') }, }) - await expect(ctx.commands.execute(agent, '/boom', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/boom', [], new AbortController().signal)) .rejects.toThrow('handler exploded') expect(lifecycleOf(agent)).toMatchObject([ { type: 'command/run', data: { name: 'boom' } }, @@ -407,7 +407,7 @@ describe('CommandRuntime', () => { handler: () => new Promise(() => undefined), }) const controller = new AbortController() - const pending = ctx.commands.execute(agent, '/hang', controller.signal) + const pending = ctx.commands.execute(agent, '/hang', [], controller.signal) // The run append must land before the abort so the pair stays complete. await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) }) controller.abort('operator cancelled command') @@ -425,8 +425,8 @@ describe('CommandRuntime', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('real')) const signal = new AbortController().signal - await ctx.commands.execute(agent, 'not a command', signal) - await ctx.commands.execute(agent, '/missing', signal) + await ctx.commands.execute(agent, 'not a command', [], signal) + await ctx.commands.execute(agent, '/missing', [], signal) expect(agent.session.events).toEqual([]) }) @@ -435,7 +435,7 @@ describe('CommandRuntime', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('mid')) agent.session.append('turn/start', { turn: 1 }) - await ctx.commands.execute(agent, '/mid', new AbortController().signal) + await ctx.commands.execute(agent, '/mid', [], new AbortController().signal) expect(agent.session.events.map(event => event.type)).toEqual([ 'turn/start', 'command/run', 'command/done', ]) @@ -460,6 +460,133 @@ describe('CommandRuntime', () => { description: 'Broken', handler: () => output as never, }) - await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected) + await expect(ctx.commands.execute(agent, '/broken', [], new AbortController().signal)).rejects.toThrow(expected) + }) +}) + +describe('image attachments', () => { + const PNG = 'AAAA' + + function storeOf() { + let saved = 0 + const store = { + imageLimits: { + maxImageBytes: 1024, maxImagesPerMessage: 2, maxMessageImageBytes: 1024, + maxImagePixels: 1_000_000, mediaTypes: ['image/png'], + }, + validateImage: vi.fn(() => Promise.resolve()), + saveImage: vi.fn((input: { mediaType: string; name?: string }) => { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, + }) + }), + } + return store + } + + function accepting(handler: CommandDefinition['handler']): CommandDefinition { + return { + name: 'vision', + description: 'accepts images', + input: { hint: '', images: true }, + handler, + } + } + + it('rejects a boolean-typed images flag violation at registration', async () => { + const ctx = await mount() + expect(() => ctx.commands.register({ + ...command('flag-type'), + input: { hint: 'x', images: 'yes' }, + } as unknown as CommandDefinition)).toThrow('command "flag-type" input images flag must be a boolean') + }) + + it('lists images acceptance on the descriptor and omits a false flag', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register(accepting(() => ({ kind: 'success' }))) + ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', images: false } }) + const byName = new Map(ctx.commands.list(agent).map(descriptor => [descriptor.name, descriptor])) + expect(byName.get('vision')?.input).toEqual({ hint: '', images: true }) + expect(byName.get('plain-input')?.input).toEqual({ hint: 'x' }) + }) + + it('settles images sent to a non-declaring command as a logged error before the handler', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const handler = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register({ ...command('deploy'), handler }) + const execution = await ctx.commands.execute( + agent, '/deploy now', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept image attachments' }) + expect(handler).not.toHaveBeenCalled() + expect(lifecycleOf(agent)).toMatchObject([ + { type: 'command/run', data: { name: 'deploy' } }, + { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept image attachments' } }, + ]) + }) + + it('settles a declaring command as a logged error when no attachment store is composed', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register(accepting(() => ({ kind: 'success' }))) + const execution = await ctx.commands.execute( + agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) + expect(execution?.result).toEqual({ + kind: 'error', + text: '/vision: image attachments are unavailable because no attachment store is composed', + }) + }) + + it('admits and hands the handler frozen ordered image blocks; plain invocations stay empty', async () => { + const ctx = await mount() + ctx.provide('attachments', storeOf()) + const { agent } = await mintAgentScope(ctx, 'a') + const seen = vi.fn((invocation: { attachments: readonly unknown[] }) => { + expect(Object.isFrozen(invocation.attachments)).toBe(true) + return { kind: 'success' as const } + }) + ctx.commands.register(accepting(seen)) + await ctx.commands.execute(agent, '/vision x', [ + { mediaType: 'image/png', data: PNG, name: 'a.png' }, + { mediaType: 'image/png', data: PNG, name: 'b.png' }, + ], new AbortController().signal) + const invocation = seen.mock.calls[0]?.[0] as { attachments: ReadonlyArray<{ type: string; attachment: { name?: string } }> } + expect(invocation.attachments.map(block => [block.type, block.attachment.name])).toEqual([ + ['image', 'a.png'], ['image', 'b.png'], + ]) + await ctx.commands.execute(agent, '/vision y', [], new AbortController().signal) + expect((seen.mock.calls[1]?.[0] as { attachments: readonly unknown[] }).attachments).toEqual([]) + }) + + it('settles an admission limit failure as a logged error result', async () => { + const ctx = await mount() + ctx.provide('attachments', storeOf()) + const { agent } = await mintAgentScope(ctx, 'a') + const handler = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register(accepting(handler)) + const three = [1, 2, 3].map(() => ({ mediaType: 'image/png' as const, data: PNG })) + const execution = await ctx.commands.execute(agent, '/vision x', three, new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'error', text: 'Upload exceeds the configured image-count limit.' }) + expect(handler).not.toHaveBeenCalled() + expect(lifecycleOf(agent).at(-1)).toMatchObject({ type: 'command/done', data: { kind: 'error' } }) + }) + + it('logs and rethrows a non-attachment admission failure', async () => { + const ctx = await mount() + const store = storeOf() + store.saveImage.mockRejectedValueOnce(new Error('disk gone')) + ctx.provide('attachments', store) + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register(accepting(() => ({ kind: 'success' }))) + await expect(ctx.commands.execute( + agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal, + )).rejects.toThrow('disk gone') + expect(lifecycleOf(agent).at(-1)).toMatchObject({ + type: 'command/done', + data: { kind: 'error', text: 'disk gone' }, + }) }) }) diff --git a/packages/interaction/commands/tsconfig.json b/packages/interaction/commands/tsconfig.json index 0504815c60..7f7bfd9ac0 100644 --- a/packages/interaction/commands/tsconfig.json +++ b/packages/interaction/commands/tsconfig.json @@ -14,12 +14,18 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../core/agent" }, { "path": "../../core/scope" }, + { + "path": "../../llm/llm" + }, { "path": "../../core/session" }, diff --git a/packages/interaction/permission-presets/tests/projection.spec.ts b/packages/interaction/permission-presets/tests/projection.spec.ts index 2c068594cc..f9bba33958 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -90,7 +90,7 @@ describe('/permission command', () => { it('switches through permission.set and logs the lifecycle pair', async () => { const { ctx, session } = await harness() const { agent, inject } = await agentFor(ctx, session) - const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/permission danger-full-access', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' }) expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') expect(inject.mock.calls[0]?.[0]).toMatchObject({ @@ -106,7 +106,7 @@ describe('/permission command', () => { it('reports the current preset and the table on bare invocation', async () => { const { ctx, session } = await harness() const { agent } = await agentFor(ctx, session) - const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/permission', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', @@ -119,7 +119,7 @@ describe('/permission command', () => { const { agent } = await agentFor(ctx, session) const before = session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done') - const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/permission yolo', [], new AbortController().signal) // The error text carries the same no-self-labelling rule as the success // texts: `permission · unknown preset "yolo" (…)`, not `unknown permission // preset`, which the row's own title already says. diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index fb59fa576d..5818d40a4b 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md -README.md: 7171997406ea43487762d9947d07426df400c78a -README.zh.md: 5c04cdabc293c9abda6bb5e77f596715ee137c6f +README.md: 67783a9369339005ba748d5cfa929ceb6fef4a70 +README.zh.md: 28d505f5f2591de9774c0e5f6412d5570a81163a diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 7171997406..67783a9369 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -16,7 +16,7 @@ While active, `plan:policy` renders the configured `section`. The plugin always The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message. -When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. +When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. The command declares `input.images`: composer image attachments ride the steered message ahead of its text block, and an invocation whose attachments have no message carrier (`/plan` or `/plan off`) returns a direct error before any mode change so the composer keeps the images. The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary. @@ -65,7 +65,7 @@ The section is stable within plan mode, but entering or leaving changes the syst #### What the model sees -`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one trimmed user text block through `agent.steer()` after plan mode is selected. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. +`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. #### Token effect diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 5c04cdabc2..28d505f5f2 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -16,7 +16,7 @@ 评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅——用户关闭请求,转而发言——会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。 -组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。 +组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。该命令声明了 `input.images`:composer 图片附件会随被 steer 的消息一起提交,位于文本块之前;附件没有消息载体的调用(`/plan` 或 `/plan off`)会在任何模式变更前直接返回错误,composer 保留图片。 Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 @@ -65,7 +65,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型所见内容 -`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一个已去除首尾空白的用户文本块。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。 +`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一条用户消息:任何已准入的图片附件作为前置图片块,之后是已去除首尾空白的文本块。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。 #### Token 影响 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index d8399ee3e1..e3eb4dd105 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -270,9 +270,16 @@ export class PlanModeController extends Service { commandCtx.commands.register({ name: 'plan', description: 'Enter or leave plan mode', - input: { hint: '[off|message]' }, - handler: ({ agent, rawInput }) => { + input: { hint: '[off|message]', images: true }, + handler: ({ agent, rawInput, attachments }) => { const message = rawInput.trim() + if (message === 'off' || message === '') { + // Attachments ride the steered message; without one they have no + // model-visible carrier, so the composer must keep them. + if (attachments.length > 0) { + return { kind: 'error', text: 'Image attachments require a plan message: /plan .' } + } + } if (message === 'off') { switch (this.set(agent, false)) { case 'committed': @@ -291,7 +298,12 @@ export class PlanModeController extends Service { } } const outcome = this.set(agent, true) - if (message !== '') agent.steer(createUserMessage({ content: [{ type: 'text', text: message }], source: { kind: 'user' } })) + if (message !== '') { + agent.steer(createUserMessage({ + content: [...attachments, { type: 'text', text: message }], + source: { kind: 'user' }, + })) + } return { kind: 'success', text: outcome === 'committed' diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index c4d41b13d8..3f2de378b4 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -561,13 +561,13 @@ describe('/plan', () => { const plainSteer = vi.fn() ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer expect(ctx.commands.list(plainAgent)).toEqual([ - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, ]) const signal = new AbortController().signal - expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined() - expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined() - const plain = await ctx.commands.execute(plainAgent, '/plan', signal) + expect(await ctx.commands.execute(plainAgent, '/mode', [], signal)).toBeUndefined() + expect(await ctx.commands.execute(plainAgent, '/review', [], signal)).toBeUndefined() + const plain = await ctx.commands.execute(plainAgent, '/plan', [], signal) expect(plain?.result).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.', @@ -579,7 +579,7 @@ describe('/plan', () => { openTurn(messageAgent.session) const messageSteer = vi.fn() ;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer - const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal) + const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', [], signal) expect(plan?.result).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.', @@ -600,7 +600,7 @@ describe('/plan', () => { const signal = new AbortController().signal const inactive = await agentWithSession(ctx, 'inactive-plan-command') - expect((await ctx.commands.execute(inactive, '/plan off', signal))?.result) + expect((await ctx.commands.execute(inactive, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode is already inactive.' }) expect(ctx.planMode.get(inactive)).toEqual({ active: false }) @@ -608,8 +608,8 @@ describe('/plan', () => { openTurn(entering.session) const enteringSteer = vi.fn() ;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer - await ctx.commands.execute(entering, '/plan', signal) - expect((await ctx.commands.execute(entering, '/plan off', signal))?.result) + await ctx.commands.execute(entering, '/plan', [], signal) + expect((await ctx.commands.execute(entering, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' }) expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false }) expect(enteringSteer).not.toHaveBeenCalled() @@ -621,10 +621,10 @@ describe('/plan', () => { openTurn(active.session) const activeSteer = vi.fn() ;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer - expect((await ctx.commands.execute(active, '/plan off', signal))?.result) + expect((await ctx.commands.execute(active, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' }) expect(ctx.planMode.get(active)).toEqual({ active: true, pending: false }) - expect((await ctx.commands.execute(active, '/plan off', signal))?.result) + expect((await ctx.commands.execute(active, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' }) expect(activeSteer).not.toHaveBeenCalled() await boundary(ctx, active, 'step-start') @@ -637,14 +637,63 @@ describe('/plan', () => { await new Promise(resolve => setImmediate(resolve)) const signal = new AbortController().signal const agent = await agentWithSession(ctx, 'idle-plan-command') - expect((await ctx.commands.execute(agent, '/plan', signal))?.result) + expect((await ctx.commands.execute(agent, '/plan', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' }) expect(foldPlanMode(agent.session.events)).toBe(true) - expect((await ctx.commands.execute(agent, '/plan off', signal))?.result) + expect((await ctx.commands.execute(agent, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode off.' }) expect(foldPlanMode(agent.session.events)).toBe(false) }) + it('rides image attachments on the steered plan message and refuses carriers without one', async () => { + const ctx = await setup() + await ctx.plugin(CommandRuntime) + await new Promise(resolve => setImmediate(resolve)) + let saved = 0 + ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 1024, maxImagesPerMessage: 4, maxMessageImageBytes: 1024, + maxImagePixels: 1_000_000, mediaTypes: ['image/png'], + }, + validateImage: () => Promise.resolve(), + saveImage: (input: { mediaType: string }) => { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + }) + }, + }) + const signal = new AbortController().signal + const images = [{ mediaType: 'image/png' as const, data: 'AAAA' }] + + const agent = await agentWithSession(ctx, 'imaged-plan-command') + openTurn(agent.session) + const steer = vi.fn() + ;(agent as unknown as { steer: typeof steer }).steer = steer + const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', images, signal) + expect(withMessage?.result.kind).toBe('success') + expect(steer).toHaveBeenCalledExactlyOnceWith({ + id: expect.any(String) as unknown, + role: 'user', + content: [ + { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-1' }) as unknown }, + { type: 'text', text: 'sketch the layout' }, + ], + source: { kind: 'user' }, + }) + + const bareAgent = await agentWithSession(ctx, 'imaged-bare-plan-command') + openTurn(bareAgent.session) + const bareSteer = vi.fn() + ;(bareAgent as unknown as { steer: typeof bareSteer }).steer = bareSteer + expect((await ctx.commands.execute(bareAgent, '/plan', images, signal))?.result) + .toEqual({ kind: 'error', text: 'Image attachments require a plan message: /plan .' }) + expect((await ctx.commands.execute(bareAgent, '/plan off', images, signal))?.result) + .toEqual({ kind: 'error', text: 'Image attachments require a plan message: /plan .' }) + expect(bareSteer).not.toHaveBeenCalled() + expect(ctx.planMode.get(bareAgent)).toEqual({ active: false }) + }) + it('removes the contributed command when the plan-mode plugin is disposed', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts index facd7e13fd..2a993f52ef 100644 --- a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts +++ b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts @@ -60,7 +60,7 @@ describe('session-log-download real Loader composition', () => { expect(context.commands.list(agent)).toContainEqual({ name: 'export', description: 'Download this Session log as a ZIP archive', }) - const execution = await context.commands.execute(agent, '/export', new AbortController().signal) + const execution = await context.commands.execute(agent, '/export', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'Session log download requested.' }) expect(session.events.map(event => event.type)).toEqual(['command/run', 'command/done']) expect(session.deriveMessages()).toEqual([]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1490a0f9f7..63380ff0d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4969,12 +4969,18 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ed622a12ee..bbe3550269 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -279,6 +279,7 @@ export const LINK_MAP: Readonly> = { ApprovalPolicy: 'approval.md', ApprovalRequest: 'approval.md', ApprovalService: 'approval.md', + EncodedImageAttachment: 'attachment.md', ImageAttachmentRef: 'attachment.md', SaveImageAttachment: 'attachment.md', StoredImageAttachment: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 95a573541c..7ac673013e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -885,6 +885,11 @@ "symbol": "ImageAttachmentLimits", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "EncodedImageAttachment", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "SaveImageAttachment", diff --git a/tsconfig.base.json b/tsconfig.base.json index 16d69db6db..aceb3f6651 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"], "@deepseek-ai/dsh-typert-generator": ["./packages/typert/generator/src/index.ts"], + "@deepseek-ai/dsh-attachment/types": ["./packages/attachment/attachment/src/types.ts"], "@deepseek-ai/dsh-session/types": ["./packages/core/session/src/types.ts"], "@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.ts"], "@deepseek-ai/dsh-session-projection/types": ["./packages/session/session-projection/src/types.ts"], From 4ed283a2ba4f811c60a4884e4b234bb6d8539561 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 19:48:30 +0800 Subject: [PATCH 05/56] fix(commands): address review-bot round on the attachment envelope - Honor a cancellation that lands during image admission before the handler runs, settling command/done with the abort reason (executor re-check after admitEncodedImages; the committed objects stay unreferenced, deferred-GC territory, now recorded in the Agent Note). - Never let a pending draft-image serialization reach claim.submit after the attempt died (dispose/session teardown race). - Refuse image removal while a command submit is in flight so the rail cannot diverge from the serialized snapshot mid-transaction. - Declare dsh-llm as a runtime peer dependency of command-goal. - Mirror the host executor's ordering and the producer grammar rejections in the fixture command plane: image checks run after command resolution (unknown names stay lifecycle-free), bare /goal and /plan//plan off with images answer the producers' error texts. - Explain the deliberate serialize/release asymmetry in the hub's commandImages plumbing. --- ...ommand-image-attachment-envelope.i18n.yaml | 4 +-- ...08-17-command-image-attachment-envelope.md | 1 + ...17-command-image-attachment-envelope.zh.md | 1 + .../client/connection/src/client/fixture.ts | 32 +++++++++++++------ .../tests/fixture-commands.client.spec.ts | 29 +++++++++++++++++ .../src/client/input/contract.ts | 4 +-- .../src/client/input/facade.ts | 18 ++++++++--- .../ui-conversation/src/client/input/hub.ts | 4 +++ .../tests/input-matrix.client.spec.tsx | 29 +++++++++++++++++ packages/goal/command-goal/package.json | 1 + packages/interaction/commands/src/index.ts | 14 ++++++++ .../commands/tests/commands.spec.ts | 22 +++++++++++++ 12 files changed, 141 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml index d7fa3d5b9d..fcd0403b9f 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md -2026-08-17-command-image-attachment-envelope.md: 89a8d8a047005d8267e3cb5e368d9ed938865494 -2026-08-17-command-image-attachment-envelope.zh.md: 27fe48fcaa80ea47fc1598f3242deaf00bff83a6 +2026-08-17-command-image-attachment-envelope.md: 64897b25f989210d5fa73f3a4f8124f6cbac73fb +2026-08-17-command-image-attachment-envelope.zh.md: d2741e294ac1e30fb6e0bd3ffd0112799e39c17e diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md index 89a8d8a047..64897b25f9 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md @@ -40,3 +40,4 @@ Registry executor enforcement, admission failure settlement, and frozen invocati - The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly. - `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), billed like any image prompt. - Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary. +- "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written". diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md index 27fe48fcaa..d2741e294a 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md @@ -40,3 +40,4 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片 - commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。 - `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),计费与任何图片提示词相同。 - 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。 +- 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 9da78d4526..a992e6ba80 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1747,16 +1747,28 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const match = /^\/(\S+)((?:\s.*)?)$/.exec(line.trim()) const name = match?.[1] const args = match?.[2] ?? '' - // Mirror the Host executor's declaration enforcement: only the - // descriptors listed with `input.images` accept an image-carrying - // submission; the fixture stores no bytes, so accepted images are - // acknowledged and dropped. - if (images.length > 0 && name !== 'goal' && name !== 'plan') { - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name: name ?? '', args, source: { kind: 'user' } } }) - const result: CommandResult = { kind: 'error', text: `/${name} does not accept image attachments` } - append(id, { type: 'command/done', data: { commandId, ...result } }) - return { ok: true, value: { commandId, result } } + // Mirror the Host image policy AFTER command resolution, matching the + // executor's order (an unknown name answers undefined and logs no + // lifecycle): the declaration rejection covers every known command + // without `input.images`, and the two producer grammar rejections cover + // the declaring commands' carrier-less lines. The fixture stores no + // bytes, so an accepted batch is acknowledged and dropped. + const known = ['permission', 'goal', 'compact', 'echo', 'plan'] + if (images.length > 0 && name !== undefined && known.includes(name)) { + const rejection = name !== 'goal' && name !== 'plan' + ? `/${name} does not accept image attachments` + : name === 'goal' && args.trim() === '' + ? 'Image attachments only accompany a goal objective: /goal or /goal edit .' + : name === 'plan' && (args.trim() === '' || args.trim() === 'off') + ? 'Image attachments require a plan message: /plan .' + : undefined + if (rejection !== undefined) { + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const result: CommandResult = { kind: 'error', text: rejection } + append(id, { type: 'command/done', data: { commandId, ...result } }) + return { ok: true, value: { commandId, result } } + } } if (name === 'permission') { const preset = args.trim() diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index c7bf0e58c8..c1c93d5c14 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -114,6 +114,35 @@ describe('createFixtureApi commands/skills', () => { const accepted = await callRemote<{ result: { kind: string } } | undefined>( rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship it', images: [png] }) expect(accepted?.result.kind).toBe('success') + const planMessage = await callRemote<{ result: { kind: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan sketch the layout', images: [png] }) + expect(planMessage?.result.kind).toBe('success') + }) + + it('mirrors the producer grammar rejections for carrier-less declaring lines', async () => { + const { rpc } = createFixtureFaces() + const png = { mediaType: 'image/png', data: 'AA==' } + const bareGoal = await callRemote<{ result: { kind: string; text?: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal', images: [png] }) + expect(bareGoal?.result).toEqual({ + kind: 'error', + text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + }) + for (const line of ['/plan', '/plan off']) { + const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line, images: [png] }) + expect(refused?.result).toEqual({ + kind: 'error', + text: 'Image attachments require a plan message: /plan .', + }) + } + }) + + it('answers no execution for an unknown name even when images accompany it', async () => { + const { rpc } = createFixtureFaces() + const png = { mediaType: 'image/png', data: 'AA==' } + expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/nope', images: [png] })) + .toBeUndefined() }) it('answers no execution for unknown names and non-command lines', async () => { diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 25d90f1312..91a2494a4d 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -35,7 +35,7 @@ export interface SessionInput extends InputTarget { setDraft(text: string): void /** Append ordered browser-owned image ids; busy admission phases refuse. */ addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id. */ + /** Remove one browser-owned image id; busy admission phases refuse. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser-owned objects no longer exist. */ pruneImages(ids: readonly DraftAttachmentId[]): void @@ -75,7 +75,7 @@ export interface InputActions { setDraft(text: string): void /** Append ordered browser-owned image ids; busy admission phases refuse. */ addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id. */ + /** Remove one browser-owned image id; busy admission phases refuse. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser-owned objects no longer exist. */ pruneImages(ids: readonly DraftAttachmentId[]): void diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index e781e783c9..b708a56c12 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -125,8 +125,13 @@ export class SessionInputShell implements SessionInput { return true } - /** Remove one image id from this draft. */ + /** + * Remove one image id from this draft. Busy admission phases refuse, like + * {@link addImages}: a removal landing while a command submit serializes + * would otherwise vanish from the rail yet still ride the in-flight send. + */ removeImage(id: DraftAttachmentId): void { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return const next = this.imageIds.filter(candidate => candidate !== id) if (next.length === this.imageIds.length) return this.imageIds = next @@ -497,11 +502,16 @@ export class SessionInputShell implements SessionInput { private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void { const imageIds = claim.images === true ? [...this.imageIds] : [] Promise.resolve() - .then(() => imageIds.length > 0 ? this.deps.commandImages.serialize(imageIds) : []) - .then(images => claim.submit(args, this.deps.actx, images)) + .then(async () => { + const images = imageIds.length > 0 ? await this.deps.commandImages.serialize(imageIds) : [] + // Serialization may outlive the attempt (large files, session + // teardown); a dead attempt must not reach the Host executor. + if (this.dead(attempt)) return undefined + return claim.submit(args, this.deps.actx, images) + }) .then( (outcome) => { - if (this.dead(attempt)) return + if (outcome === undefined || this.dead(attempt)) return if (outcome.kind === 'success' && imageIds.length > 0) { const submitted = new Set(imageIds) this.imageIds = this.imageIds.filter(id => !submitted.has(id)) diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 0cc3408620..ee02233b2f 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -80,6 +80,10 @@ export class InputHub implements SessionInputResolver { steerQueue: () => { void this.steerQueue(session, shell) }, commandImages: { serialize: ids => this.conversation().serializeDraftImages(ids), + // Asymmetric with serialize on purpose: release settles AFTER the + // submit RPC, where session teardown may already have unloaded the + // conversation service (the same tolerance as the scope disposer + // above); leaked preview URLs then die with the document. release: (ids) => { const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined for (const imageId of ids) conversation?.releaseDraftImage(imageId) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index b173cae66c..bf5c8c01df 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -212,6 +212,35 @@ describe('matrix row: claimed with images', () => { expect(release).not.toHaveBeenCalled() expect(shell.snapshot.phase).toBe('claimed') }) + + it('a disposed shell never lets a pending serialization reach claim.submit', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) + let resolveSerialize!: (images: readonly SubmitImageAttachment[]) => void + const { shell, textarea, claim } = bench({ + submit, + serialize: () => new Promise((resolve) => { resolveSerialize = resolve }), + }) + claim('/goal ', '目标', true) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(resolveSerialize).toBeDefined() }) + shell.dispose() + resolveSerialize([{ mediaType: 'image/png', data: 'AA==' }]) + await Promise.resolve() + await Promise.resolve() + expect(submit).not.toHaveBeenCalled() + }) + + it('image removal is refused while a command submit is in flight', async () => { + const submit = vi.fn(() => new Promise(() => {})) // never settles + const { shell, textarea, claim } = bench({ submit, serialize: () => Promise.resolve([]) }) + claim('/goal ', '目标', true) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(shell.snapshot.phase).toBe('submitting') + act(() => { shell.removeImage(img) }) + expect(shell.snapshot.imageIds).toEqual([img]) + }) }) describe('matrix row: submitting', () => { diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 8cb103c4c2..10286b41ed 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index df812d6334..9e078938ed 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -128,6 +128,11 @@ function abortError(signal: AbortSignal): Error { return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted') } +/** The signal's normalized abort error when it is already aborted. */ +function cancellationOf(signal: AbortSignal): Error | undefined { + return signal.aborted ? abortError(signal) : undefined +} + /** Render arbitrary thrown values without trusting their string coercion. */ function renderThrown(value: unknown): string { try { @@ -368,6 +373,15 @@ export class CommandRuntime extends TypertRemoteService { this.settleThrown(agent.session, parsed.name, commandId, error) throw error } + // Cancellation must be honored BEFORE the handler runs: admission may + // await slow storage, and a handler entered after the caller cancelled + // would mutate state the retrying caller then duplicates. (The committed + // image objects stay unreferenced and are deferred-GC territory.) + const cancelledDuringAdmission = cancellationOf(signal) + if (cancelledDuringAdmission !== undefined) { + this.settleThrown(agent.session, parsed.name, commandId, cancelledDuringAdmission) + throw cancelledDuringAdmission + } } const invocation = Object.freeze({ commandId, agent, rawInput: parsed.rawInput, attachments, signal }) let result: CommandResult diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index d3ac6e8bff..87da98f52d 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -574,6 +574,28 @@ describe('image attachments', () => { expect(lifecycleOf(agent).at(-1)).toMatchObject({ type: 'command/done', data: { kind: 'error' } }) }) + it('honors a cancellation that lands during admission before entering the handler', async () => { + const ctx = await mount() + const controller = new AbortController() + const store = storeOf() + store.saveImage.mockImplementationOnce((input: { mediaType: string }) => { + controller.abort('operator cancelled during admission') + return Promise.resolve({ attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }) + }) + ctx.provide('attachments', store) + const { agent } = await mintAgentScope(ctx, 'a') + const handler = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register(accepting(handler)) + await expect(ctx.commands.execute( + agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], controller.signal, + )).rejects.toThrow('operator cancelled during admission') + expect(handler).not.toHaveBeenCalled() + expect(lifecycleOf(agent).at(-1)).toMatchObject({ + type: 'command/done', + data: { kind: 'error', text: 'operator cancelled during admission' }, + }) + }) + it('logs and rethrows a non-attachment admission failure', async () => { const ctx = await mount() const store = storeOf() From 56efd81d19a06c3323ac24349f3fb7435443f4a9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 20:32:14 +0800 Subject: [PATCH 06/56] fix(ci): sync release version and module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 7 +++++-- docs/module-graph.zh.md | 7 +++++-- packages/code-runtime/code-runtime-python/package.json | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 826a05243c..ee437d987d 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 2eb7c748ee0bcf6eb63d200841e35f2606958a24 -module-graph.zh.md: 35a5615914711da1f52e2ecfb938c2e134f6afcb +module-graph.md: d3e310004a55c2f9cc2509dfe48b28cef678645a +module-graph.zh.md: e33b58a508d68e1bf4f4a78c4726b02f2b14443f diff --git a/docs/module-graph.md b/docs/module-graph.md index 2eb7c748ee..d3e310004a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -524,8 +524,10 @@ flowchart TD pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol pkg_commands --> pkg_agent + pkg_commands --> pkg_attachment pkg_commands --> pkg_brand pkg_commands --> pkg_invariants + pkg_commands --> pkg_llm pkg_commands --> pkg_scope pkg_commands --> pkg_session pkg_commands --> pkg_typert_protocol @@ -616,6 +618,7 @@ flowchart TD pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_invariants + pkg_command_goal --> pkg_llm pkg_goal_round_driver --> pkg_agent pkg_goal_round_driver --> pkg_goal pkg_goal_round_driver --> pkg_invariants @@ -1502,7 +1505,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | @@ -1521,7 +1524,7 @@ flowchart TD | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 35a5615914..e33b58a508 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -526,8 +526,10 @@ flowchart TD pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol pkg_commands --> pkg_agent + pkg_commands --> pkg_attachment pkg_commands --> pkg_brand pkg_commands --> pkg_invariants + pkg_commands --> pkg_llm pkg_commands --> pkg_scope pkg_commands --> pkg_session pkg_commands --> pkg_typert_protocol @@ -618,6 +620,7 @@ flowchart TD pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_invariants + pkg_command_goal --> pkg_llm pkg_goal_round_driver --> pkg_agent pkg_goal_round_driver --> pkg_goal pkg_goal_round_driver --> pkg_invariants @@ -1504,7 +1507,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | @@ -1523,7 +1526,7 @@ flowchart TD | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 2b7734dc94..7cea7a25b5 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, From 3b869b6f6d1870dcd46e1e7dd94f500a7df4369f Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 17 Aug 2026 20:42:59 +0800 Subject: [PATCH 07/56] refactor: separate persistent Bash changes from runtime packaging --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/terminal.i18n.yaml | 4 +- docs/subsystems/terminal.md | 2 +- docs/subsystems/terminal.zh.md | 2 +- .../shell/tool-bash-persistent/src/index.ts | 3 +- .../tests/loader-composition.spec.ts | 6 +- .../tool-bash-persistent/tests/tools.spec.ts | 4 +- .../subprocess-local/src/process-inspector.ts | 80 +++---------------- .../subprocess-local/src/terminal.ts | 16 ++-- .../tests/process-inspector.spec.ts | 71 +--------------- .../terminal/terminal-bash/src/sanitize.ts | 5 +- packages/terminal/terminal/src/index.ts | 3 - 13 files changed, 34 insertions(+), 166 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e5b36bf91c..ef4931f765 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 2e4aad7532b061e8328f25a53c2c3b0c4bb4dfa0 +config-catalog.md: 82f6d26c79d32c6952f3bc11c96fa1c2ddceecdc config-catalog.zh.md: 958d3115447db37de248bbf30b0744308ff8dbb8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2e4aad7532..82f6d26c79 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2370,7 +2370,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:406`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) diff --git a/docs/subsystems/terminal.i18n.yaml b/docs/subsystems/terminal.i18n.yaml index 96f05cc50a..859104d415 100644 --- a/docs/subsystems/terminal.i18n.yaml +++ b/docs/subsystems/terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/terminal.md -terminal.md: c7031143117a514f8579a22fed07a1461babb15e -terminal.zh.md: 7c469c028e8b3da7c9d5012f9f49c1ae65a9bd03 +terminal.md: eedf6157b256e83d3b57e07bf738429773b87574 +terminal.zh.md: 3cc2c281e8c2090bb7e92808f932be689fd0b710 diff --git a/docs/subsystems/terminal.md b/docs/subsystems/terminal.md index c703114311..eedf6157b2 100644 --- a/docs/subsystems/terminal.md +++ b/docs/subsystems/terminal.md @@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts) +Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts) diff --git a/docs/subsystems/terminal.zh.md b/docs/subsystems/terminal.zh.md index 7c469c028e..3cc2c281e8 100644 --- a/docs/subsystems/terminal.zh.md +++ b/docs/subsystems/terminal.zh.md @@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts) +Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts) diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index b13911cb25..16d127bbe2 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -16,7 +15,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' -const SHELL_PROMPT = CONTROLLED_PROMPT +const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' // One page is enough to find a just-emitted completion marker; the full // scrollback is assembled only when a command settles or needs partial output. diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index f04981f2cd..55e89bc9f4 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -84,8 +84,8 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { ' config:', ' pollIntervalMs: 10', ' exactProbeAfterMs: 20', - ' idleSilenceMs: 3000', - ' handoffGraceMs: 500', + ' idleSilenceMs: 100', + ' handoffGraceMs: 100', ' scrollbackLines: 20000', ' timeoutMs: 2000', ' disposeGraceMs: 500', @@ -131,9 +131,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { }) expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash']) - const startedAt = Date.now() await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested') - expect(Date.now() - startedAt).toBeLessThan(2_000) const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"')) expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) expect(observed).not.toContain('DSH_PERSISTENT_BASH') diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 88a9ffa3f4..b3de46643c 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import TerminalSessionService, { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSession, @@ -100,7 +100,7 @@ type StubMode = | 'paged-scrollback' class StubPtySession implements TerminalBackendSession { - readonly motd = CONTROLLED_PROMPT + readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' readonly pid = 123 statusValue: TerminalSessionStatus = { kind: 'running' } scrollback = this.motd diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index 06a44665cf..f31de010de 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -13,9 +13,9 @@ export interface ProcessIdentity { /** Injectable OS process operations used by one local PTY session. */ export interface ProcessInspector { foregroundPgid(shellPid: number): number | undefined - isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean + isStdinWaiting(pgid: number): boolean /** Return the root and its current transitive descendants, children first. */ - processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[] + processTree(rootPid: number): ProcessIdentity[] /** Return current members of one POSIX process session when the platform exposes them. */ processSession(sessionId: number): ProcessIdentity[] /** Return whether the exact identity remains a non-quiescent process. */ @@ -226,26 +226,12 @@ function syscallWaitsOnStdin( return false } -function processWaitsOnStdin( - internals: ProcessInspectorInternals, - pid: number, - processGroupId: number, - table: SyscallTable, -): boolean { - if (readLinuxStat(internals, pid)?.pgrp !== processGroupId) return false - for (const tid of numericEntries(internals, `/proc/${pid}/task`)) { - const syscall = readSyscall(internals, pid, tid) - if (syscall !== undefined && syscallWaitsOnStdin(internals, pid, syscall, table)) return true - } - return false -} - abstract class PosixProcessInspector implements ProcessInspector { constructor(protected readonly internals: ProcessInspectorInternals) {} abstract foregroundPgid(shellPid: number): number | undefined - abstract isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean - abstract processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[] + abstract isStdinWaiting(pgid: number): boolean + abstract processTree(rootPid: number): ProcessIdentity[] abstract processSession(sessionId: number): ProcessIdentity[] abstract isAlive(identity: ProcessIdentity): boolean @@ -284,34 +270,6 @@ function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdent return result } -function linuxProcessTreeFromChildren( - internals: ProcessInspectorInternals, - rootPid: number, -): ProcessIdentity[] | undefined { - const root = readLinuxStat(internals, rootPid) - if (root === undefined) return [] - const visited = new Set() - const result: ProcessIdentity[] = [] - const visit = (entry: ProcStat): boolean => { - if (visited.has(entry.pid)) return true - visited.add(entry.pid) - let children: string - try { - children = internals.readFile(`/proc/${entry.pid}/task/${entry.pid}/children`) - } catch (_unreadableChildren) { - return false - } - for (const token of children.trim().split(/\s+/)) { - if (token.length === 0 || !/^\d+$/.test(token)) continue - const child = readLinuxStat(internals, Number(token)) - if (child !== undefined && !visit(child)) return false - } - result.push({ pid: entry.pid, started: entry.started }) - return true - } - return visit(root) ? result : undefined -} - class LinuxProcessInspector extends PosixProcessInspector { constructor( private readonly arch: NodeJS.Architecture, @@ -325,32 +283,20 @@ class LinuxProcessInspector extends PosixProcessInspector { return tpgid !== undefined && tpgid > 0 ? tpgid : undefined } - isStdinWaiting(pgid: number, scanNamespace = true): boolean { + isStdinWaiting(pgid: number): boolean { const table = SYSCALLS[this.arch] if (table === undefined) return false - // A POSIX process group is normally led by PID == PGID. Interactive shells - // wait on stdin in that leader, so inspect it before walking the whole PID - // namespace. Large container PID namespaces otherwise make every PTY - // readiness poll scan thousands of unrelated processes. - if (processWaitsOnStdin(this.internals, pgid, pgid, table)) return true - if (!scanNamespace) return false for (const pid of numericEntries(this.internals, '/proc')) { - if (pid === pgid) continue - if (processWaitsOnStdin(this.internals, pid, pgid, table)) return true + if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue + for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) { + const syscall = readSyscall(this.internals, pid, tid) + if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true + } } return false } - processTree(rootPid: number, scanNamespace = true): ProcessIdentity[] { - // Linux exposes each process's direct children without requiring a scan of - // the container's whole PID namespace. Fall back for kernels or procfs - // mounts that do not provide the children file. - const rooted = linuxProcessTreeFromChildren(this.internals, rootPid) - if (rooted !== undefined) return rooted - if (!scanNamespace) { - const root = readLinuxStat(this.internals, rootPid) - return root === undefined ? [] : [{ pid: root.pid, started: root.started }] - } + processTree(rootPid: number): ProcessIdentity[] { const entries = numericEntries(this.internals, '/proc').flatMap((pid) => { const stat = readLinuxStat(this.internals, pid) return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }] @@ -392,11 +338,11 @@ class MacProcessInspector extends PosixProcessInspector { } } - isStdinWaiting(_pgid: number, _scanNamespace = true): boolean { + isStdinWaiting(_pgid: number): boolean { return false } - processTree(rootPid: number, _scanNamespace = true): ProcessIdentity[] { + processTree(rootPid: number): ProcessIdentity[] { return processTree(macProcessTable(this.internals), rootPid) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index bfba98dfb9..6d818c8a7f 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -57,7 +57,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private readonly graceMs: number, ) { this.pid = terminal.pid - this.rootIdentity = inspector.processTree(this.pid, false).find(member => member.pid === this.pid) + this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) this.done = this.outcome.promise this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) }) this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => { @@ -81,14 +81,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { // Local inspection is synchronous; the seam returns a promise for remote transports. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async inspectForeground(): Promise { - // Readiness polling may run every few milliseconds. Track the rooted tree - // here, but reserve the full process-session sweep for teardown. - this.descendants(false, false) + this.descendants() const processGroupId = this.inspector.foregroundPgid(this.pid) if (processGroupId === undefined) return undefined return { processGroupId, - inputWaiting: this.inspector.isStdinWaiting(processGroupId, false), + inputWaiting: this.inspector.isStdinWaiting(processGroupId), } } @@ -143,22 +141,20 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { return members.filter(member => this.inspector.isAlive(member)) } - private descendants(includeSession = true, scanNamespace = true): ProcessIdentity[] { + private descendants(): ProcessIdentity[] { // Adopt newly scanned members only while the numeric root pid provably // still carries the spawned shell's start identity: after the shell dies, // a recycled pid's tree and session must not donate an unrelated // process's children to this session's signalling. Already-adopted // members keep their own start identities, which every signal rechecks. - const tree = this.inspector.processTree(this.pid, scanNamespace) + const tree = this.inspector.processTree(this.pid) const root = tree.find(member => member.pid === this.pid) const rootVerified = this.rootIdentity !== undefined && root !== undefined && root.started === this.rootIdentity.started this.trackedDescendants = this.survivors(this.unionMembers( this.trackedDescendants, - ...rootVerified - ? [tree, ...includeSession ? [this.inspector.processSession(this.pid)] : []] - : [], + ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : [], ).filter(member => member.pid !== this.pid)) return this.trackedDescendants } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index 2402681461..c90a7b3490 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -95,9 +95,6 @@ describe('Linux process inspector', () => { fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501')) fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10)) fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12)) - fake.files.set('/proc/10/task/10/children', '12') - fake.files.set('/proc/12/task/12/children', '13') - fake.files.set('/proc/13/task/13/children', '') const inspector = createProcessInspector('linux', 'x64', fake.internals) expect(inspector.foregroundPgid(10)).toBe(40) expect(inspector.foregroundPgid(11)).toBeUndefined() @@ -127,65 +124,9 @@ describe('Linux process inspector', () => { expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']]) }) - it('walks a rooted process tree without enumerating the PID namespace', () => { - const fake = fakeInternals() - fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) - fake.files.set('/proc/10/task/10/children', '11') - fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) - fake.files.set('/proc/11/task/11/children', '') - - expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ - { pid: 11, started: '501' }, - { pid: 10, started: '500' }, - ]) - }) - - it('contains cycles in the procfs children index', () => { - const fake = fakeInternals() - fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) - fake.files.set('/proc/10/task/10/children', '11') - fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) - fake.files.set('/proc/11/task/11/children', '10') - - expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ - { pid: 11, started: '501' }, - { pid: 10, started: '500' }, - ]) - }) - - it('falls back to the PID namespace when a descendant children index is unreadable', () => { - const fake = fakeInternals() - fake.dirs.set('/proc', ['10', '11', '12']) - fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) - fake.files.set('/proc/10/task/10/children', '12 invalid 11') - fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) - - expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ - { pid: 11, started: '501' }, - { pid: 10, started: '500' }, - ]) - }) - - it('keeps readiness inspection local when procfs has no children index', () => { - const fake = fakeInternals() - fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) - const inspector = createProcessInspector('linux', 'x64', fake.internals) - - expect(inspector.processTree(10, false)).toEqual([{ pid: 10, started: '500' }]) - expect(inspector.isStdinWaiting(10, false)).toBe(false) - - const readFile = fake.internals.readFile.bind(fake.internals) - let statReads = 0 - fake.internals.readFile = (path) => { - if (path === '/proc/10/stat' && statReads++ > 0) throw new Error('process exited') - return readFile(path) - } - expect(inspector.processTree(10, false)).toEqual([]) - }) - it('detects read, select, poll, and epoll waits across non-leader threads', () => { const fake = fakeInternals() - fake.dirs.set('/proc', ['77', '100', '101']) + fake.dirs.set('/proc', ['100', '101']) fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2')) fake.dirs.set('/proc/100/task', ['100']) @@ -215,16 +156,6 @@ describe('Linux process inspector', () => { expect(inspector.isStdinWaiting(77)).toBe(true) }) - it('checks a waiting process-group leader without scanning the PID namespace', () => { - const fake = fakeInternals() - fake.files.set('/proc/77/stat', stat(77, 77, 77, 77, '1')) - fake.dirs.set('/proc/77/task', ['77']) - fake.files.set('/proc/77/task/77/syscall', syscall(0, 0)) - - const inspector = createProcessInspector('linux', 'x64', fake.internals) - expect(inspector.isStdinWaiting(77)).toBe(true) - }) - it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => { const fake = fakeInternals() fake.dirs.set('/proc', ['100']) diff --git a/packages/terminal/terminal-bash/src/sanitize.ts b/packages/terminal/terminal-bash/src/sanitize.ts index 73f9d74e7b..1f28315fb1 100644 --- a/packages/terminal/terminal-bash/src/sanitize.ts +++ b/packages/terminal/terminal-bash/src/sanitize.ts @@ -2,11 +2,12 @@ import { Buffer } from 'node:buffer' -export { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' - /** OSC marker emitted by the controlled bash before each prompt. */ export const PROMPT_MARKER_PREFIX = '133;D;' +/** Exact printable prompt emitted after the private marker. */ +export const CONTROLLED_PROMPT = 'dsh> ' + /** One sanitized chunk plus whether it contained the owned prompt marker. */ export interface SanitizedChunk { text: string diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index 6f782e3bab..d9e5444465 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -42,9 +42,6 @@ export type { } from './types.ts' export { TerminalBackendCleanupError } from './types.ts' -/** Printable prompt shared by terminal backends and persistent shell consumers. */ -export const CONTROLLED_PROMPT = 'dsh> ' - /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */ export type TerminalSessionId = TerminalSessionIdValue From 10d0895ed623a3692e5dcd29e8164aff9c45d317 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 17 Aug 2026 20:43:08 +0800 Subject: [PATCH 08/56] test(python-sdk): cover shipped preset runtime closure --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- scripts/verify-runtime-closure.spec.ts | 103 +++++++++++++++ scripts/verify-runtime-closure.ts | 121 +++++++++++------- 5 files changed, 180 insertions(+), 52 deletions(-) create mode 100644 scripts/verify-runtime-closure.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 79be5c180b..37e39c7dbf 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: fa2f86893b730aa1ba020bd568d268ec8d9d6239 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 509bec18edb9923dd4d60d4ecf30d4fbcd9cc6d5 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 3715aa5b1148eb97faa04d5ac5778cceb47b97fc +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: a9632deb847d0aa24f376edb017bd70c4ff81302 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index fa2f86893b..3715aa5b11 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 509bec18ed..a9632deb84 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,7 +36,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都显式列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建流水线与产物 diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts new file mode 100644 index 0000000000..ccce67c498 --- /dev/null +++ b/scripts/verify-runtime-closure.spec.ts @@ -0,0 +1,103 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { verifyRuntimeClosure } from './verify-runtime-closure.ts' + +const roots: string[] = [] + +function fixture(files: Record>): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-runtime-closure-')) + roots.push(root) + for (const [relative, value] of Object.entries(files)) { + const path = join(root, relative) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`) + } + return root +} + +const platforms = { + 'linux-x64': { tag: 'manylinux_2_28_x86_64', executable: 'runtime-linux-x64' }, + 'linux-arm64': { tag: 'manylinux_2_28_aarch64', executable: 'runtime-linux-arm64' }, + 'macos-arm64': { tag: 'macosx_14_0_arm64', executable: 'runtime-macos-arm64' }, +} + +function workspace(root: string, name: string, manifest: Record): void { + const packageName = name.replace('@scope/', '') + const path = join(root, 'packages/core', packageName, 'package.json') + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${JSON.stringify({ name, ...manifest }, null, 2)}\n`) +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('verifyRuntimeClosure', () => { + it('requires only plugins active for a Linux or macOS target', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` +- id: tools + name: cordis:group + group: true + config: + - id: shared + name: '@scope/shared' + - id: linux + name: '@scope/linux' + disabled: !!js process.platform !== 'linux' + - id: macos + name: '@scope/macos' + disabled: !!js process.platform !== 'darwin' +`, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.presetCount).toBe(1) + expect(result.failures).toEqual([ + 'standard preset -> @scope/linux (linux-arm64, linux-x64)', + 'standard preset -> @scope/macos (macos-arm64)', + ]) + }) + + it('treats an unsupported disabled expression as active on every target', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` +- id: conditional + name: '@scope/conditional' + disabled: !!js process.env.DSH_DISABLE_CONDITIONAL === '1' +`, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.failures).toEqual([ + 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64)', + ]) + }) + + it('retains the required workspace-peer closure check', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/root': 'workspace:^' } }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/minimal/agent.cordis.yml': '[]\n', + }) + workspace(root, '@scope/root', { + peerDependencies: { '@scope/required': 'workspace:^', '@scope/optional': 'workspace:^' }, + peerDependenciesMeta: { '@scope/optional': { optional: true } }, + }) + workspace(root, '@scope/required', {}) + workspace(root, '@scope/optional', {}) + + const result = await verifyRuntimeClosure(root) + + expect(result.workspacePackageCount).toBe(1) + expect(result.failures).toEqual(['runtime -> @scope/root -> @scope/required']) + }) +}) diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index 1287542dc2..c62ef68e37 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -30,62 +30,87 @@ interface RuntimePlatform { type RuntimePlatformManifest = Record -const root = resolve(import.meta.dirname, '..') -const { values } = parseArgs({ - args: process.argv.slice(2), - options: { manifest: { type: 'string' } }, -}) -const runtimeManifestPath = resolve(root, values.manifest ?? 'python/sdk-runtime/package.json') -const runtimeManifest = await loadManifest(runtimeManifestPath) -const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime' -const workspace = await loadWorkspacePackages() -const runtimeDependencies = runtimeManifest.dependencies ?? {} -const platforms = await loadJson(resolve(root, 'python/sdk-runtime/platforms.json')) -const parents = new Map() -const queue: string[] = [] - -for (const dependency of Object.keys(runtimeDependencies).sort()) { - if (!workspace.has(dependency)) continue - parents.set(dependency, undefined) - queue.push(dependency) +export interface RuntimeClosureResult { + failures: string[] + presetCount: number + workspacePackageCount: number } -const failures = await missingPresetPlugins(runtimeDependencies, platforms) -for (let index = 0; index < queue.length; index += 1) { - const packageName = queue[index] - if (packageName === undefined) continue - const current = workspace.get(packageName) - if (current === undefined) continue - const peers = current.manifest.peerDependencies ?? {} - const peerMeta = current.manifest.peerDependenciesMeta ?? {} - for (const peer of Object.keys(peers).sort()) { - if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue - if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue - failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`) - } - const dependencies = { - ...current.manifest.dependencies, - ...current.manifest.optionalDependencies, - } - for (const dependency of Object.keys(dependencies).sort()) { - if (!workspace.has(dependency) || parents.has(dependency)) continue - parents.set(dependency, packageName) +/** + * Check that the runtime manifest contains every shipped-preset plugin and workspace peer. + * @param root repository root containing the runtime manifest and shipped presets. + * @param manifestPath runtime manifest path relative to {@link root}. + * @returns the discovered preset count, reachable workspace package count, and violations. + */ +export async function verifyRuntimeClosure( + root: string, + manifestPath = 'python/sdk-runtime/package.json', +): Promise { + const runtimeManifest = await loadManifest(resolve(root, manifestPath)) + const runtimeName = runtimeManifest.name ?? manifestPath + const workspace = await loadWorkspacePackages(root) + const runtimeDependencies = runtimeManifest.dependencies ?? {} + const platforms = await loadJson(resolve(root, 'python/sdk-runtime/platforms.json')) + const parents = new Map() + const queue: string[] = [] + + for (const dependency of Object.keys(runtimeDependencies).sort()) { + if (!workspace.has(dependency)) continue + parents.set(dependency, undefined) queue.push(dependency) } + + const failures = await missingPresetPlugins(root, runtimeDependencies, platforms) + for (let index = 0; index < queue.length; index += 1) { + const packageName = queue[index] + if (packageName === undefined) continue + const current = workspace.get(packageName) + if (current === undefined) continue + const peers = current.manifest.peerDependencies ?? {} + const peerMeta = current.manifest.peerDependenciesMeta ?? {} + for (const peer of Object.keys(peers).sort()) { + if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue + if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue + failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`) + } + const dependencies = { + ...current.manifest.dependencies, + ...current.manifest.optionalDependencies, + } + for (const dependency of Object.keys(dependencies).sort()) { + if (!workspace.has(dependency) || parents.has(dependency)) continue + parents.set(dependency, packageName) + queue.push(dependency) + } + } + + return { + failures, + presetCount: globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length, + workspacePackageCount: queue.length, + } } -if (failures.length > 0) { - console.error('verify-runtime-closure: preset plugins or required workspace peers are missing from python/sdk-runtime dependencies:') - for (const failure of failures) console.error(` ${failure}`) - process.exit(1) +if (import.meta.main) { + const root = resolve(import.meta.dirname, '..') + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { manifest: { type: 'string' } }, + }) + const result = await verifyRuntimeClosure(root, values.manifest) + if (result.failures.length > 0) { + console.error('verify-runtime-closure: preset plugins or required workspace peers are missing from python/sdk-runtime dependencies:') + for (const failure of result.failures) console.error(` ${failure}`) + process.exitCode = 1 + } else { + console.log( + `verify-runtime-closure: ${result.presetCount} agent presets and ${result.workspacePackageCount} workspace packages form a closed runtime dependency graph.`, + ) + } } -const presetCount = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length -console.log( - `verify-runtime-closure: ${presetCount} agent presets and ${queue.length} workspace packages form a closed runtime dependency graph.`, -) - async function missingPresetPlugins( + root: string, runtimeDependencies: Readonly>, platforms: RuntimePlatformManifest, ): Promise { @@ -161,7 +186,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -async function loadWorkspacePackages(): Promise> { +async function loadWorkspacePackages(root: string): Promise> { const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }) .sort() .map(relative => resolve(root, relative)) From 042f8fd9719c350060c3f2a14868657e1ec87df9 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 17 Aug 2026 21:23:00 +0800 Subject: [PATCH 09/56] fix(release): align code runtime Python version --- packages/code-runtime/code-runtime-python/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 2b7734dc94..7cea7a25b5 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, From cd4f8b7f46b374dd6d4625034dfc64765d52a8c0 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 01:43:20 +0800 Subject: [PATCH 10/56] fix(subagent): preserve Claude Code failure facts --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 10 +- ...ude-code-and-codex-subagent-backends.zh.md | 10 +- ...agent-noninteractive-permissions.i18n.yaml | 4 +- ...uct-subagent-noninteractive-permissions.md | 8 +- ...-subagent-noninteractive-permissions.zh.md | 8 +- ...8-product-subagent-failure-facts.i18n.yaml | 6 + ...26-08-18-product-subagent-failure-facts.md | 74 ++++++ ...08-18-product-subagent-failure-facts.zh.md | 74 ++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../fixtures/subagent-result-diagnostic.ts | 2 +- .../session.jsonl | 8 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 10 +- .../subagent-claude-code/README.zh.md | 10 +- .../subagent-claude-code/src/index.ts | 32 ++- .../subagent/subagent-claude-code/src/run.ts | 224 +++++++++++++++--- .../tests/real-product.spec.ts | 68 +++++- .../tests/subagent-claude-code.spec.ts | 205 +++++++++++++--- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- 24 files changed, 648 insertions(+), 129 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 777fff4e2a..9c2c6dd814 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: f65c0626ad22db8f3e7d2a543c7aa87e58df54d4 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 97ac527b8e89cc07d65aa28102ba43d648b1b64c +2026-08-04-claude-code-and-codex-subagent-backends.md: 829dca8dbd79b408fcfcfd1d88490d793ad4b5ee +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 063bd8c9a59a1b1eccf4893a00efe12a80fbe03f diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index f65c0626ad..829dca8dbd 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions, and the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns version-pinned product categories, lifecycle stages, and process outcomes exposed through the same diagnostic. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -52,9 +52,9 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp The public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. -The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. When a permission denial or unattended callback contributes to that failure, the result may additionally carry the bounded, non-assistant diagnostic owned by the non-interactive permissions decision. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without permission detail. +The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every other result remains `error`, but its bounded diagnostic preserves the four exact SDK error subtypes, fixed categories for invalid success and missing result, a safe `unknown` fallback, the current `query-start`, `query-run`, `process`, or `teardown` stage, and any observed exit code and signal. A contributing permission decision follows that structured failure line. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without either diagnostic fact. -Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable. +Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract directly: the runtime-only DeepSeek key becomes `ANTHROPIC_AUTH_TOKEN`, the fixed official base gains `/anthropic`, and the main and subagent model variables select the documented DeepSeek models. It starts the production provider and real SDK/CLI, requires one random nonce as the complete answer, persists no credential in settings, and waits for every managed handle to exit. @@ -66,7 +66,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, a real `error_max_turns` result, a process exit with its outcome, safe permission diagnostics, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. Package tests pin the complete SDK error union, all four stages, unknown fallback, independent code and signal fields, sanitization, success and cancellation omission, and concurrent-run isolation. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. -Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. +Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic containing provider-owned permission facts or version-pinned structured failure facts. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 97ac527b8e..063bd8c9a5 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 模式选择与安全权限决定,[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)则负责通过同一诊断公开锁定产品版本的类别、生命周期阶段与进程结果。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -52,9 +52,9 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 -只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。当权限拒绝或无人值守回调参与了该失败时,结果还可以携带由非交互权限决策负责的有界、非 assistant 诊断。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带权限说明。 +只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。其他所有结果仍成为 `error`,但其有界诊断会保留四种准确 SDK 错误子类型、标记为错误的成功消息与结果缺失所对应的固定类别、安全的 `unknown` 回退、当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,以及已观测到的退出码和信号。若权限决定也参与失败,它会跟在结构化失败行之后。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。 -启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。 +启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 带密钥 Claude Code e2e 直接使用官方 DeepSeek Claude Code 约定:仅在运行时提供的 DeepSeek 密钥会映射为 `ANTHROPIC_AUTH_TOKEN`,固定的官方基础 URL 会追加 `/anthropic`,主模型与 subagent 模型变量会选择文档所示的 DeepSeek 模型。该测试会启动生产提供方与真实 SDK 和 CLI,要求一个随机数作为完整答案,不会把任何凭据持久化到设置中,并等待所有受管句柄退出。 @@ -66,7 +66,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、真实的 `error_max_turns` 结果、携带进程结果的提前退出、安全权限诊断、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。包测试固定完整 SDK 错误联合、四个阶段、unknown 回退、相互独立的退出码与信号字段、脱敏、成功与取消时省略诊断,以及并发运行隔离。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -90,6 +90,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl 用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 -每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 +每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断,其中包含由提供方拥有的权限事实,或锁定版本产品提供的结构化失败事实。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 940ca44aae..6b3e5d13ff 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: df1f0d9939e951f16070729615a3779f1f7c2ddc -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 982b4409e08a506dec828db15c8c4aa5fcc36883 +2026-08-15-product-subagent-noninteractive-permissions.md: 9327412cfdd306f7867f989c8cfc091941cb26e6 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: a7992b58a14aff94f93397bfa0aa21b9fe727fb2 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index df1f0d9939..9327412cfd 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -44,9 +44,9 @@ The Provider overrides only those thread fields. `CODEX_HOME`, project configura ### Failure diagnostic -`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. +`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns non-permission product categories, lifecycle stages, and process outcomes carried by the same field. -Each product records only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`. +Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Claude Code places its structured failure line before the latest contributing permission fact; Codex retains its permission-only diagnostic in this product version. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`. The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior. @@ -63,7 +63,7 @@ The foreground consumer presents the stop-reason headline, then the optional dia ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared diagnostic presentation while the model-facing product tool schemas contain no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared foreground and Job diagnostic presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered @@ -83,6 +83,6 @@ Package tests pin every allowed and rejected Config value, the exact SDK and app Profiles can select each product's native restricted, automatic, planning/edit-accepting where supported, or bypass behavior before the Provider starts, while both safe defaults never ask a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences. -Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement. +Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. The same field can also carry the separately owned structured failure facts. It can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound the complete text before result settlement. The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Other Providers remain valid without producing a diagnostic or exposing a permission-mode Config. diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 982b4409e0..a7992b58a1 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -44,9 +44,9 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 ### 失败诊断 -`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。 +`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责由同一字段承载的非权限产品类别、生命周期阶段与进程结果。 -每个产品都只记录有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 +每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。Claude Code 会把结构化失败行放在最新参与失败的权限事实之前;当前产品版本中的 Codex 仍只生成权限诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。 @@ -63,7 +63,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录共享诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录前台与 Job 共享的诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered @@ -83,6 +83,6 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 Profile 可以在提供方启动前选择各产品原生的受限、自动、在产品支持时仅规划/编辑放行,或 bypass 行为,而两个安全默认值都绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。 -权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。 +权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。同一字段还可以承载由另一项决策负责的结构化失败事实。它可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前对完整文本完成脱敏和限长。 本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml new file mode 100644 index 0000000000..b27044310b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md +2026-08-18-product-subagent-failure-facts.md: 4380e36d172f395692d2d84c97d45cba95701f9f +2026-08-18-product-subagent-failure-facts.zh.md: d601becdf14bd74ae871a66d4798ffe9c49b6490 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md new file mode 100644 index 0000000000..4380e36d17 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md @@ -0,0 +1,74 @@ +# Agent Note: Product subagents expose bounded structured failure facts + +Status: implemented + +English | [中文](2026-08-18-product-subagent-failure-facts.zh.md) + +## Problem + +The [Claude Code and Codex product providers](2026-08-04-claude-code-and-codex-subagent-backends.md) receive structured product failures, but a published run historically flattened most of them to the shared `error` stop reason. Product logs retained detail that the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not use to distinguish a product limit, an execution failure, or an early process exit. + +Copying SDK error text, app-server payloads, or stderr into the result would expose task text, paths, environment values, credentials, or product internals. Adding shared error fields would also make the provider-neutral [subagent seam](2026-06-21-subagent-capability-seam.md) own product version vocabularies that change independently. + +## Decision + +Each product Provider owns the mapping from its pinned official error union, current operation, and managed process outcome to one fixed safe diagnostic line. `SubagentResult` remains unchanged: consumers receive the existing bounded `diagnostic` string and do not parse its product-private fields. + +### Safe diagnostic + +The structured line has this fixed order: + +```text +Product subagent failure (product: ; stage: ; category: ; exit code: ; signal: ) +``` + +The Provider omits unavailable exit fields. Exit code and signal are independent facts and are each retained when observed. A contributing permission decision from the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) follows the structured line; the latest safe permission fact remains operation-local. The shared result boundary limits the complete text to 4096 UTF-8 bytes. + +Successful results and local cancellation expose no failure fact. Raw product errors, stderr, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic. Startup and cleanup rejections use the same safe line in their Error message while retaining the original failure only on the internal cause chain and in Host logging. + +### Claude Code facts + +Agent SDK 0.3.220 defines four error subtypes: `error_during_execution`, `error_max_turns`, `error_max_budget_usd`, and `error_max_structured_output_retries`. The Claude Code Provider preserves each exact subtype as the category while keeping the shared stop reason `error`. An error-marked or blank success uses `invalid-success`, a missing result uses `missing-result`, a process exit before an SDK terminal result uses `process-exit`, and an unrecognized value or exception uses `unknown` without copying the value. + +| Stage | Owned operation | Observable failure | +| --- | --- | --- | +| `query-start` | Native executable resolution, SDK query construction, and unpublished rollback | `start()` rejects with fixed safe facts and any process outcome observed before rollback | +| `query-run` | Published SDK message iteration and strict terminal-result validation | The run resolves as `error` with the exact known subtype or a fixed result category | +| `process` | Managed CLI exits before the SDK supplies a terminal result | The run resolves as `error` with `process-exit` and the available exit code and signal | +| `teardown` | Query close and managed process-tree release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | + +The Codex Provider retains its existing result mapping: `contextWindowExceeded` is `max-tokens`, other turn failures remain `error`, and permission-related paths may carry their existing safe diagnostic. Other Codex error-info members are not represented as shared categories by this decision's current implementation. + +### Ownership and lifecycle + +| Fact or resource | Owner | Consumer behavior | +| --- | --- | --- | +| Product error category | Pinned official SDK or app-server version | The Provider maps only the declared structured union and uses `unknown` outside it | +| Current failure stage | Product Provider operation | Derived at the failure site; never persisted or used as a recovery state | +| Exit code and signal | `dsh-subprocess` process handle | The Provider displays observed values without inferring missing ones | +| Diagnostic bytes and delivery | `dsh-subagent`, foreground tool, and Job runtime | The same bounded text is presented separately from assistant output in both scheduling modes | +| Raw product failure | Product runtime and Host log | It remains internal and never becomes model-visible result text | + +## Verification + +Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. The real SDK/CLI fixture produces an actual `error_max_turns` result and an actual early process exit while proving whole-tree quiescence. The keyless ACP snapshot records the same failure diagnostic in foreground error output, the background completion notice, and `job_output`. + +## Alternatives considered + +**Return raw SDK errors, app-server payloads, or stderr.** These values can contain commands, paths, workspace content, environment values, credentials, or upstream prose. A fixed allowlisted mapping preserves actionable facts without expanding the model-visible trust boundary. + +**Add a shared product-error enum or structured result fields.** Claude Code and Codex version their error unions independently. A shared enum would duplicate those authorities and force unrelated Providers and consumers to track product releases. + +**Parse generic stderr and exception messages.** Free-form text is neither stable nor safe. Only pinned structured product fields and the managed process outcome qualify as diagnostic input. + +**Persist stages or add a recovery controller.** The stage is derived from the current call site only when a failure is reported. Persistence, retries, resume, and remediation need separate ownership and user contracts. + +**Map product limits to new shared stop reasons.** Claude Code turn and budget limits are not token-window exhaustion, and an error category does not establish refusal semantics. Existing stop reasons remain unchanged. + +## Consequences + +The parent can distinguish important Claude Code product limits, invalid terminal results, unknown query failures, and early process exits without receiving raw product text. Foreground and background scheduling preserve the same fact because both consume one `SubagentResult`. + +The diagnostic is display text rather than a new public protocol. Callers may present it but must not branch on its punctuation or product-private category names. A pinned product-version upgrade must update the Provider mapping and evidence when its official error union changes. + +This decision adds no product session persistence, retry policy, recovery state, stderr classifier, authentication or configuration taxonomy, progress stream, or human interaction path. diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md new file mode 100644 index 0000000000..d601becdf1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md @@ -0,0 +1,74 @@ +# Agent Note: 产品 subagent 公开有界结构化失败事实 + +Status: implemented + +[English](2026-08-18-product-subagent-failure-facts.md) | 中文 + +## Problem + +[Claude Code 与 Codex 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)会收到结构化产品失败,但已发布运行以往会把其中大多数压成共享的 `error` 终止原因。产品日志保留了细节,前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.md)却无法据此区分产品限制、执行失败或进程提前退出。 + +若把 SDK 错误文本、app-server payload 或 stderr 复制进结果,就会暴露任务文本、路径、环境值、凭证或产品内部信息。若增加共享错误字段,又会让提供方无关的 [subagent seam](2026-06-21-subagent-capability-seam.md)拥有彼此独立变化的产品版本词汇。 + +## Decision + +每个产品提供方分别拥有从锁定版本官方错误联合、当前操作和受管进程结果到一行固定安全诊断的映射。`SubagentResult` 保持不变:消费方仍接收现有的有界 `diagnostic` 字符串,而且不解析其中由产品私有的字段。 + +### 安全诊断 + +结构化行采用以下固定顺序: + +```text +Product subagent failure (product: ; stage: ; category: ; exit code: ; signal: ) +``` + +提供方会省略不可用的退出字段。退出码与信号是相互独立的事实,只要已观测到就分别保留。来自[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)且参与失败的权限决定会跟在结构化行之后;最新的安全权限事实仍只属于当前操作。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。 + +成功结果与本地取消都不公开失败事实。原始产品错误、stderr、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。启动与清理拒绝会在 Error 消息中使用同一安全行,而原始失败只保留在内部 cause 链与 Host 日志中。 + +### Claude Code 事实 + +Agent SDK 0.3.220 定义四种错误子类型:`error_during_execution`、`error_max_turns`、`error_max_budget_usd` 和 `error_max_structured_output_retries`。Claude Code 提供方会把每种准确子类型保留为类别,同时维持共享终止原因 `error`。标记为错误或内容空白的成功消息使用 `invalid-success`,缺失结果使用 `missing-result`,SDK 给出终态结果前发生的进程退出使用 `process-exit`,无法识别的值或异常使用 `unknown`,且不会复制原值。 + +| 阶段 | 归属操作 | 可观察失败 | +| --- | --- | --- | +| `query-start` | 原生可执行文件解析、SDK query 构造与未发布回滚 | `start()` 以固定安全事实和回滚前已观测到的进程结果拒绝 | +| `query-run` | 已发布 SDK 消息迭代与严格终态结果校验 | 运行以 `error` 兑现,并携带准确已知子类型或固定结果类别 | +| `process` | SDK 提供终态结果之前受管 CLI 已退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码和信号 | +| `teardown` | Query 关闭与受管进程树释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | + +Codex 提供方保留既有结果映射:`contextWindowExceeded` 是 `max-tokens`,其他轮次失败仍是 `error`,权限相关路径可以携带既有安全诊断。本决策的当前实现不会把其他 Codex error-info 成员表示为共享类别。 + +### 所有权与生命周期 + +| 事实或资源 | Owner | 消费方行为 | +| --- | --- | --- | +| 产品错误类别 | 锁定版本的官方 SDK 或 app-server | 提供方只映射已声明的结构化联合,并对联合外值使用 `unknown` | +| 当前失败阶段 | 产品提供方操作 | 只在失败点派生;绝不持久化,也不作为恢复状态 | +| 退出码与信号 | `dsh-subprocess` 进程句柄 | 提供方展示已观测值,不推测缺失值 | +| 诊断字节与送达 | `dsh-subagent`、前台工具与 Job 运行时 | 两种调度模式都把同一份有界文本与 assistant 输出分开呈现 | +| 原始产品失败 | 产品运行时与 Host 日志 | 只保留在内部,绝不成为模型可见的结果文本 | + +## Verification + +Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。真实 SDK/CLI fixture 会产生真实的 `error_max_turns` 结果与真实的进程提前退出,并证明整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录同一份失败诊断。 + +## Alternatives considered + +**返回原始 SDK 错误、app-server payload 或 stderr。** 这些值可能包含命令、路径、工作区内容、环境值、凭证或上游文本。固定白名单映射可以保留可操作事实,同时不扩大模型可见的信任边界。 + +**增加共享产品错误 enum 或结构化结果字段。** Claude Code 与 Codex 各自独立版本化错误联合。共享 enum 会复制这些权威,并迫使无关提供方和消费方跟随产品版本。 + +**解析通用 stderr 与异常消息。** 自由文本既不稳定也不安全。只有锁定版本产品提供的结构化字段和受管进程结果可以成为诊断输入。 + +**持久化阶段或增加恢复控制器。** 阶段只在报告失败时从当前调用点派生。持久化、重试、resume 与修复需要独立的所有权和用户约定。 + +**把产品限制映射为新的共享终止原因。** Claude Code 的轮次和预算限制并不表示 token 窗口耗尽,错误类别也不能证明拒绝语义。既有终止原因保持不变。 + +## Consequences + +父 agent 可以区分重要的 Claude Code 产品限制、无效终态结果、未知 query 失败和进程提前退出,而不会收到原始产品文本。前台与后台调度会保留同一事实,因为二者都消费同一个 `SubagentResult`。 + +诊断只是展示文本,不是新的公开协议。调用方可以呈现它,但不得根据其标点或产品私有类别名称进行分支。锁定产品版本升级并改变官方错误联合时,必须同步更新提供方映射与证据。 + +本决策不增加产品会话持久化、重试策略、恢复状态、stderr 分类器、身份验证或配置分类体系、进度流或人工交互路径。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9136d2952b..497fe74b47 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: ebef0167d5ecbe0c71d201cd2cc07962ba89c48d -config-catalog.zh.md: 4b2ffba0e931c4c515097950e3e69b5744cb5f37 +config-catalog.md: c4c70a9bc8a0ae964189b7b1a22443ec18b4b8a2 +config-catalog.zh.md: b034155795efad1c808d3147c8223b29160c6283 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ebef0167d5..c4c70a9bc8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2103,7 +2103,7 @@ export interface Config { export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` -Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) +Source: [`packages/subagent/subagent-claude-code/src/index.ts:36`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4b2ffba0e9..b034155795 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2105,7 +2105,7 @@ export interface Config { export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` -来源:[`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) +来源:[`packages/subagent/subagent-claude-code/src/index.ts:36`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts index 3f9f9ebe55..56626802a4 100644 --- a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts +++ b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts @@ -11,7 +11,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' export const name = 'subagent-result-diagnostic' export const inject = ['subagents'] -const DIAGNOSTIC = 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt' +const DIAGNOSTIC = 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)' class DiagnosticProvider implements SubagentProvider { readonly name = 'snapshot-diagnostic' diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl index f1c5ffe374..b9a725162e 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"92e33995-2f02-4ad5-aec1-9df82cf4d583"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}} -{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"4e84e7b3-40c1-488e-b119-45e8bd7ce448"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"f63d21ab-ccdc-44f2-9a96-4c60b46e5318"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1786781990613,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1786781990618,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -25,12 +25,12 @@ {"type":"assistant/chunk","seq":23,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"2fb444e2-7a52-4963-988e-b1ecbc3744d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}} -{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"}]}} +{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Claude Co…"},"role":"user","id":"05f93dde-37e9-40e7-92d5-f8de526a8bee"}]}} {"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_diagnostic_background"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"3377f724-b4a7-4ce1-bed7-774f174917d6"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1786781990627,"data":{"turn":1,"step":2}} {"type":"agent/inbox/spliced","seq":29,"time":1786781990627,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":30,"time":1786781990632,"data":{"turn":1,"step":3}} -{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"},"surfaceOp":"append"} +{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Claude Co…"},"role":"user","id":"05f93dde-37e9-40e7-92d5-f8de526a8bee"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} {"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}} @@ -38,7 +38,7 @@ {"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f43f988b-bc08-4811-8671-8edc0613f0d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} {"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} -{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]"}],"isError":false}],"role":"user","id":"6785120f-ae46-48d0-9f3f-d6cd1e6fc5d7"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]"}],"isError":false}],"role":"user","id":"10ac5635-c519-4f69-ab5a-cb0f930e9df0"}},"sourceEventSeqs":[38],"surfaceOp":"append"} {"type":"step/end","seq":40,"time":1786781990640,"data":{"turn":1,"step":3}} {"type":"step/start","seq":41,"time":1786781990645,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 0185afd2de..c2fe8df4db 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: be3b2262addc487e545fed1f792600a9a5ca24c0 -README.zh.md: 7ea1b8ca7243790afd387b04d776088cea012718 +README.md: 21beb0a0534e601f9dd26d36f53d1fb5f09b4e07 +README.zh.md: c83999ee7b2d38e9c4ee57ba74df25217998bffd diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index be3b2262ad..21beb0a053 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -8,15 +8,15 @@ This package registers the fixed `claude-code` subagent provider. Each accepted `start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`. -The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every SDK error subtype, an error-marked success, a missing answer, iterator failure, protocol failure, or process failure maps to `error`; the provider produces neither `max-tokens` nor `refusal`. +The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every failure still maps to `error`: the four error subtypes in Agent SDK 0.3.220 retain their exact category, an error-marked or blank success becomes `invalid-success`, a missing result becomes `missing-result`, an unclassified query failure becomes `unknown`, and an early CLI exit becomes `process-exit`. The diagnostic also names the current `query-start`, `query-run`, `process`, or `teardown` stage and independently includes an observed exit code and signal. The provider produces neither `max-tokens` nor `refusal`. -Local cancellation wins the result race and maps to `aborted`. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains only on the internal cause chain. Result failure and independent teardown failure remain separate. ## Native settings and interaction The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks. -Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. +Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. When both facts contribute to a failed run, `SubagentResult.diagnostic` contains the structured failure line first and the latest safe permission decision second; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs expose neither captured fact. ## Capabilities and context @@ -93,7 +93,7 @@ Independent of the parent request cache. Reuse depends only on Claude Code's own #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. That diagnostic can distinguish the fixed SDK error category, lifecycle stage, and observed process outcome without copying raw product text. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the same final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session. #### Token effect @@ -107,7 +107,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. -- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced with its lifecycle stage and the safe `unknown` fallback rather than a separate public classification; the plugin provides no installer or login flow. - **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. - **No human interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending. - **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, while generic Job ids, notices, and status come from the shared job runtime. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 7ea1b8ca72..c83999ee7b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -8,15 +8,15 @@ `start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。 -SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有 SDK 错误子类型、标记为错误的成功消息、缺失答案、迭代器失败、协议失败或进程失败都映射为 `error`;该提供方不会产生 `max-tokens` 或 `refusal`。 +SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有失败仍映射为 `error`:Agent SDK 0.3.220 的四种错误子类型保留准确类别;标记为错误或内容空白的成功消息成为 `invalid-success`;缺失结果成为 `missing-result`;未分类的 query 失败成为 `unknown`;CLI 提前退出成为 `process-exit`。诊断还会注明当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,并分别保留已观测到的退出码与信号。该提供方不会产生 `max-tokens` 或 `refusal`。 -本地取消会在结果竞态中胜出并映射为 `aborted`。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链上。结果失败与独立的清理失败仍彼此分离。 ## 原生设置与交互 提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。 -每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。当两类事实共同参与一次失败运行时,`SubagentResult.diagnostic` 会先写入结构化失败行,再写入最新的安全权限决定;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消都不会公开已捕获的事实。 ## 能力与上下文 @@ -93,7 +93,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。该诊断可以区分固定 SDK 错误类别、生命周期阶段和已观测的进程结果,而不复制原始产品文本。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开同一最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -107,7 +107,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 -- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败会公开其生命周期阶段与安全的 `unknown` 回退,而不会增加单独的公开分类;本插件不提供安装程序或登录流程。 - **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。 - **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 4095ca8f8e..1359b1d642 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -21,6 +21,7 @@ import { CLAUDE_CODE_PERMISSION_MODES, DEFAULT_CLAUDE_CODE_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, + claudeCodeStartupFailure, startClaudeCodeRun, type ClaudeCodePermissionMode, type ClaudeCodeRunSpec, @@ -78,17 +79,29 @@ class ClaudeCodeProvider implements SubagentProvider { 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', ) } - const executable = await this.ctx.subprocess.resolveExecutable( - 'claude', - this.config.env, - request.signal, - ) - const spec: ClaudeCodeRunSpec = { - cwd: resolveChildCwd( + let cwd: string + let executable: string + try { + cwd = resolveChildCwd( 'subagent-claude-code', undefined, parentCwd, - ), + ) + executable = await this.ctx.subprocess.resolveExecutable( + 'claude', + this.config.env, + request.signal, + ) + } catch (error: unknown) { + if (request.signal.aborted) { + throw new Error( + 'subagent-claude-code: request was aborted before SDK startup', + ) + } + throw claudeCodeStartupFailure(error) + } + const spec: ClaudeCodeRunSpec = { + cwd, executable, permissionMode: this.config.permissionMode, env: this.config.env, @@ -96,7 +109,8 @@ class ClaudeCodeProvider implements SubagentProvider { spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), onError: (error, stopReason) => { this.ctx.logger.warn( - `subagent-claude-code: child run failed (${stopReason}): ${error.message}`, + `subagent-claude-code: child run failed (${stopReason}): %o`, + error, ) }, } diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 82dcfb4eb4..3e5b3c0905 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -28,6 +28,7 @@ import { import { scrubbedParentEnv, type SubprocessHandle, + type SubprocessOutcome, type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { @@ -57,6 +58,83 @@ const SUPPORTED_UNATTENDED_DIALOG_KINDS = [ 'refusal_fallback_prompt', ] satisfies NonNullable +type ClaudeCodeErrorSubtype = Exclude + +type ClaudeCodeFailureStage = + | 'query-start' + | 'query-run' + | 'process' + | 'teardown' + +type ClaudeCodeFailureCategory = + | ClaudeCodeErrorSubtype + | 'invalid-success' + | 'missing-result' + | 'process-exit' + | 'unknown' + +interface ClaudeCodeFailureFacts { + readonly stage: ClaudeCodeFailureStage + readonly category: ClaudeCodeFailureCategory + readonly outcome?: SubprocessOutcome | undefined +} + +function failureDiagnostic(facts: ClaudeCodeFailureFacts): string { + const fields = [ + 'product: Claude Code', + `stage: ${facts.stage}`, + `category: ${facts.category}`, + ] + const exitCode = facts.outcome?.exitCode + if (exitCode !== null && exitCode !== undefined) { + fields.push(`exit code: ${exitCode}`) + } + const signal = facts.outcome?.signal + if (signal !== null && signal !== undefined) { + fields.push(`signal: ${signal}`) + } + return `Product subagent failure (${fields.join('; ')})` +} + +class ClaudeCodeFailure extends Error { + constructor( + readonly facts: ClaudeCodeFailureFacts, + cause?: unknown, + ) { + super( + `subagent-claude-code: ${failureDiagnostic(facts)}`, + cause === undefined ? undefined : { cause }, + ) + this.name = 'ClaudeCodeFailure' + } +} + +function sdkFailureCategory( + subtype: string, +): ClaudeCodeErrorSubtype | 'unknown' { + switch (subtype) { + case 'error_during_execution': + case 'error_max_turns': + case 'error_max_budget_usd': + case 'error_max_structured_output_retries': + return subtype + default: + return 'unknown' + } +} + +/** + * Hide an unpublished product startup failure behind fixed safe facts. + * @param cause - original host-side failure retained only on the Error cause chain. + * @returns a rejection safe to expose through the subagent start boundary. + */ +export function claudeCodeStartupFailure(cause: unknown): Error { + return new ClaudeCodeFailure({ + stage: 'query-start', + category: 'unknown', + }, cause) +} + function unattendedDiagnostic( mode: ClaudeCodePermissionMode, request: 'tool permission' | 'MCP elicitation' | 'user dialog', @@ -90,6 +168,10 @@ function thrown(value: unknown): Error { /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) } + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} /* jscpd:ignore-end */ /** @@ -120,15 +202,23 @@ export function textTask(prompt: readonly ContentBlock[]): string { * @returns exact final text for a successful, non-error result. */ export function successfulResult(message: SDKResultMessage): string { - if ( - message.subtype !== 'success' - || message.is_error - || message.result.trim().length === 0 - ) { - const detail = message.subtype === 'success' - ? 'success result was marked as an error or contained no answer' - : message.errors.join('; ') || message.subtype - throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`) + if (message.subtype !== 'success') { + const category = sdkFailureCategory(message.subtype) + const detail = category === 'unknown' + ? undefined + : message.errors.join('; ') + throw new ClaudeCodeFailure( + { stage: 'query-run', category }, + detail === undefined || detail.length === 0 + ? undefined + : new Error(detail), + ) + } + if (message.is_error || message.result.trim().length === 0) { + throw new ClaudeCodeFailure({ + stage: 'query-run', + category: 'invalid-success', + }) } return message.result } @@ -154,7 +244,10 @@ export async function consumeClaudeQuery( answer = successfulResult(message) } if (answer === undefined) { - throw new Error('subagent-claude-code: Claude Code ended without a result') + throw new ClaudeCodeFailure({ + stage: 'query-run', + category: 'missing-result', + }) } return { output: [{ type: 'text', text: answer }], @@ -173,6 +266,7 @@ export async function disposeClaudeCodeChild( child: SubprocessHandle, ): Promise { const failures: Error[] = [] + let outcome: SubprocessOutcome | undefined try { query?.close() } catch (error: unknown) { @@ -188,17 +282,24 @@ export async function disposeClaudeCodeChild( } } try { - await child.done + outcome = await child.done } catch (error: unknown) { failures.push(thrown(error)) } const firstFailure = failures[0] - if (failures.length === 1 && firstFailure !== undefined) throw firstFailure - if (failures.length > 1) { + if (firstFailure !== undefined) { + const facts = { + stage: 'teardown', + category: 'unknown', + outcome, + } as const + if (failures.length === 1) { + throw new ClaudeCodeFailure(facts, firstFailure) + } throw new AggregateError( - failures, - 'subagent-claude-code: query and process cleanup failed', + failures.map(failure => new ClaudeCodeFailure(facts, failure)), + `subagent-claude-code: ${failureDiagnostic(facts)}`, ) } } @@ -296,9 +397,21 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let query: Query | undefined - let diagnostic: string | undefined - const captureDiagnostic = (value: string): void => { - diagnostic = value + let processOutcome: SubprocessOutcome | undefined + let failureDetail: string | undefined + let permissionDetail: string | undefined + const capturePermissionDiagnostic = (value: string): void => { + permissionDetail = value + } + const collectDiagnostic = (): string => [failureDetail, permissionDetail] + .filter((value): value is string => value !== undefined) + .join('\n') + const captureChild = (captured: SubprocessHandle): void => { + child = captured + void captured.done.then( + (outcome: SubprocessOutcome) => { processOutcome = outcome }, + () => undefined, + ) } try { query = officialQuery({ @@ -306,10 +419,8 @@ export async function startClaudeCodeRun( options: claudeQueryOptions( spec, controller, - (captured) => { - child = captured - }, - captureDiagnostic, + captureChild, + capturePermissionDiagnostic, ), }) if (child === undefined || child.pid <= 0) { @@ -323,46 +434,83 @@ export async function startClaudeCodeRun( } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = controller.signal.aborted + await Promise.resolve() + const startupOutcome = processOutcome + const startupFacts = { + stage: 'query-start', + category: 'unknown', + outcome: startupOutcome, + } as const + const startupFailure = (): ClaudeCodeFailure => new ClaudeCodeFailure( + startupFacts, + thrown(error), + ) requestCancel() if (child !== undefined) { try { await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { + const failure = startupFailure() throw new AggregateError( - [thrown(error), thrown(disposeError)], - 'subagent-claude-code: startup failed and CLI cleanup also failed', + [failure, thrown(disposeError)], + `${failure.message}; startup cleanup also failed`, ) } } else if (query !== undefined) { try { query.close() } catch (disposeError: unknown) { + const failure = startupFailure() throw new AggregateError( - [thrown(error), thrown(disposeError)], - 'subagent-claude-code: startup failed and query cleanup also failed', + [ + failure, + new ClaudeCodeFailure({ + stage: 'teardown', + category: 'unknown', + }, thrown(disposeError)), + ], + `${failure.message}; startup cleanup also failed`, ) } } - // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. - if (cancelledBeforeCleanup || request.signal.aborted) { + if (cancelledBeforeCleanup || isAborted(request.signal)) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw thrown(error) + throw startupFailure() } const publishedQuery = query const publishedChild = child const result = settleRunResult({ - attempt: () => consumeClaudeQuery(publishedQuery, () => { - captureDiagnostic(unattendedDiagnostic( - spec.permissionMode, - 'tool permission', - 'denied', - 'Claude Code denied the request before an interactive prompt', - )) - }), + attempt: async () => { + try { + return await consumeClaudeQuery(publishedQuery, () => { + capturePermissionDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'tool permission', + 'denied', + 'Claude Code denied the request before an interactive prompt', + )) + }) + } catch (error: unknown) { + await Promise.resolve() + const facts = error instanceof ClaudeCodeFailure + ? { ...error.facts, outcome: processOutcome } + : processOutcome === undefined + ? { stage: 'query-run', category: 'unknown' } as const + : { + stage: 'process', + category: 'process-exit', + outcome: processOutcome, + } as const + failureDetail = failureDiagnostic(facts) + throw error instanceof ClaudeCodeFailure + ? error + : new ClaudeCodeFailure(facts, thrown(error)) + } + }, collectOutput: () => [], - collectDiagnostic: () => diagnostic, + collectDiagnostic, cancelled: () => controller.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index a2e7111ece..5e55e9c521 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -21,7 +21,11 @@ import { Context } from '@deepseek-ai/cordis' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { + SubprocessHandle, + SubprocessOutcome, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' import type { ClaudeCodePermissionMode } from '../src/run.ts' @@ -32,6 +36,7 @@ import { } from './messages-fixture.ts' const observedSdkMessages = vi.hoisted((): SDKMessage[] => []) +const sdkTestOverrides = vi.hoisted((): { maxTurns?: number } => ({})) vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => { const actual = await importOriginal< @@ -39,8 +44,13 @@ vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => { >() return { ...actual, - query(options: Parameters[0]): Query { - const query = actual.query(options) + query(params: Parameters[0]): Query { + const query = actual.query(sdkTestOverrides.maxTurns === undefined + ? params + : { + ...params, + options: { ...params.options, maxTurns: sdkTestOverrides.maxTurns }, + }) // Observe the real SDK stream without replacing its protocol or CLI. return new Proxy(query, { get(target, property) { @@ -112,6 +122,7 @@ afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } observedSdkMessages.length = 0 + delete sdkTestOverrides.maxTurns }) interface RealHarness { @@ -216,6 +227,17 @@ async function expectQuiescent( } } +function expectedProcessFailure(outcome: SubprocessOutcome): string { + const fields = [ + 'product: Claude Code', + 'stage: process', + 'category: process-exit', + ] + if (outcome.exitCode !== null) fields.push(`exit code: ${outcome.exitCode}`) + if (outcome.signal !== null) fields.push(`signal: ${outcome.signal}`) + return `Product subagent failure (${fields.join('; ')})` +} + function startRequest( harness: RealHarness, prompt: string, @@ -294,14 +316,49 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 await expectQuiescent(harness.handles) }) - it('maps a real CLI process failure to error', async () => { + it('maps a real SDK max-turns result to safe query-run facts', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-max-turns-')) + roots.push(root) + const target = join(root, 'max-turns.txt') + sdkTestOverrides.maxTurns = 1 + const { harness, fixture } = await realHarness({ + kind: 'tool-use', + toolName: 'Write', + input: { + file_path: target, + content: 'real-sdk-max-turns', + }, + }, 'bypassPermissions') + const run = await startRequest(harness, 'Exercise the SDK max-turns result.') + const result = await run.result + expect(observedSdkMessages + .filter(message => message.type === 'result') + .map(message => message.subtype)).toEqual(['error_max_turns']) + expect(result).toMatchObject({ + output: [], + stopReason: 'error', + }) + expect(result.diagnostic).toContain( + 'product: Claude Code; stage: query-run; category: error_max_turns', + ) + expect(readFileSync(target, 'utf8')).toBe('real-sdk-max-turns') + expect(result.diagnostic).not.toContain(target) + expect(result.diagnostic).not.toContain('real-sdk-max-turns') + await run.dispose() + expect(fixture.requests).toHaveLength(1) + await expectQuiescent(harness.handles) + }) + + it('maps a real CLI process failure to its exit outcome', async () => { const { harness, fixture } = await realHarness({ kind: 'hold' }) const run = await startRequest(harness, 'Exercise the failure path.') await fixture.requestStarted expect(harness.handles).toHaveLength(1) harness.handles[0]!.terminate() + const outcome = await harness.handles[0]!.done await expect(run.result).resolves.toEqual({ output: [], + diagnostic: expectedProcessFailure(outcome), stopReason: 'error', }) await run.dispose() @@ -330,10 +387,11 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 }, { timeout: 30_000 }) expect(existsSync(target)).toBe(false) harness.handles[0]!.terminate() + const outcome = await harness.handles[0]!.done const result = await run.result expect(result).toEqual({ output: [], - diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + diagnostic: `${expectedProcessFailure(outcome)}\nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt`, stopReason: 'error', }) expect(result.diagnostic).not.toContain(target) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index b5be0987ca..372ed6269b 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -192,6 +192,25 @@ function failure( } as SDKResultMessage } +function expectedFailureDiagnostic( + stage: 'query-start' | 'query-run' | 'process' | 'teardown', + category: string, + outcome?: Partial, +): string { + const fields = [ + 'product: Claude Code', + `stage: ${stage}`, + `category: ${category}`, + ] + if (outcome?.exitCode !== null && outcome?.exitCode !== undefined) { + fields.push(`exit code: ${outcome.exitCode}`) + } + if (outcome?.signal !== null && outcome?.signal !== undefined) { + fields.push(`signal: ${outcome.signal}`) + } + return `Product subagent failure (${fields.join('; ')})` +} + function permissionDenied(): SDKPermissionDeniedMessage { return { type: 'system', @@ -397,7 +416,18 @@ describe('task admission and package contracts', () => { resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) await expect(ctx.subagents.start('claude-code', request())) - .rejects.toThrow('claude missing from PATH') + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + expect(queryMock).not.toHaveBeenCalled() + + const resolutionAbort = new AbortController() + resolveExecutable.mockImplementationOnce(async () => { + resolutionAbort.abort(new Error('parent cancelled executable resolution')) + throw new Error('SECRET_TOKEN from executable resolution') + }) + await expect(ctx.subagents.start( + 'claude-code', + request(undefined, resolutionAbort.signal), + )).rejects.toThrow('aborted before SDK startup') expect(queryMock).not.toHaveBeenCalled() const run = await ctx.subagents.start('claude-code', request()) @@ -405,11 +435,13 @@ describe('task admission and package contracts', () => { child.stdout.end() await expect(run.result).resolves.toEqual({ output: [], + diagnostic: expectedFailureDiagnostic('query-run', 'missing-result'), stopReason: 'error', }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining( - 'subagent-claude-code: child run failed (error):', - )) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('subagent-claude-code: child run failed (error):'), + expect.any(Error), + ) expect(resolveExecutable).toHaveBeenCalledWith( 'claude', expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }), @@ -711,17 +743,34 @@ describe('query options and result mapping', () => { it('accepts only a non-error success with a non-blank final result', () => { expect(successfulResult(success('exact final'))).toBe('exact final') expect(() => successfulResult(success('answer', true))) - .toThrow('marked as an error') + .toThrow(expectedFailureDiagnostic('query-run', 'invalid-success')) expect(() => successfulResult(success(' \n '))) - .toThrow('contained no answer') - expect(() => successfulResult(failure( + .toThrow(expectedFailureDiagnostic('query-run', 'invalid-success')) + const sdkFailure = () => successfulResult(failure( 'error_during_execution', - ['first', 'second'], - ))).toThrow('first; second') + ['SECRET_TOKEN', '/private/secret.txt'], + )) + expect(sdkFailure).toThrow(expectedFailureDiagnostic( + 'query-run', + 'error_during_execution', + )) + expect(sdkFailure).not.toThrow('SECRET_TOKEN') + expect(sdkFailure).not.toThrow('/private/secret.txt') expect(() => successfulResult(failure( 'error_max_turns', [], - ))).toThrow('error_max_turns') + ))).toThrow(expectedFailureDiagnostic('query-run', 'error_max_turns')) + + const unknown = { + type: 'result', + subtype: 'future_failure', + is_error: true, + errors: ['SECRET_TOKEN'], + } as unknown as SDKResultMessage + expect(() => successfulResult(unknown)) + .toThrow(expectedFailureDiagnostic('query-run', 'unknown')) + expect(() => successfulResult(unknown)).not.toThrow('future_failure') + expect(() => successfulResult(unknown)).not.toThrow('SECRET_TOKEN') }) it('consumes the complete stream and keeps the latest strict success', async () => { @@ -736,7 +785,7 @@ describe('query options and result mapping', () => { }) await expect(consumeClaudeQuery( queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), - )).rejects.toThrow('ended without a result') + )).rejects.toThrow(expectedFailureDiagnostic('query-run', 'missing-result')) const onPermissionDenied = vi.fn() await expect(consumeClaudeQuery(queryFrom([ @@ -790,6 +839,7 @@ describe('run publication, cancellation, and settlement', () => { ) await expect(run.result).resolves.toEqual({ output: [], + diagnostic: expectedFailureDiagnostic('query-run', subtype), stopReason: 'error', }) expect(onError).toHaveBeenCalledWith( @@ -809,7 +859,7 @@ describe('run publication, cancellation, and settlement', () => { const result = await run.result expect(result).toEqual({ output: [], - diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + diagnostic: `${expectedFailureDiagnostic('query-run', 'error_during_execution')}\nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt`, stopReason: 'error', }) expect(result.diagnostic).not.toContain('SECRET_TOKEN') @@ -851,6 +901,10 @@ describe('run publication, cancellation, and settlement', () => { }) await expect(failed.result).resolves.toEqual({ output: [], + diagnostic: expectedFailureDiagnostic( + 'query-run', + 'error_during_execution', + ), stopReason: 'error', }) await Promise.all([completed.dispose(), failed.dispose()]) @@ -864,26 +918,70 @@ describe('run publication, cancellation, and settlement', () => { const run = await startClaudeCodeRun(request(), fixture.spec) await expect(run.result).resolves.toEqual({ output: [], + diagnostic: expectedFailureDiagnostic('query-run', 'unknown'), stopReason: 'error', }) await run.dispose() }) - it('maps invalid success and missing result to error', async () => { - for (const messages of [ - [success('answer', true)], - [success('')], - [{ type: 'system', subtype: 'init' } as SDKMessage], - ]) { + it('maps invalid success and missing result to fixed query-run facts', async () => { + for (const [messages, category] of [ + [[success('answer', true)], 'invalid-success'], + [[success('')], 'invalid-success'], + [[{ type: 'system', subtype: 'init' } as SDKMessage], 'missing-result'], + ] as const) { const fixture = fakeRun(messages) const run = await startClaudeCodeRun(request(), fixture.spec) - await expect(run.result).resolves.toMatchObject({ + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('query-run', category), stopReason: 'error', }) await run.dispose() } }) + it('reports an early process exit with independent code and signal facts', async () => { + const outcomes: SubprocessOutcome[] = [ + { exitCode: 23, signal: null }, + { exitCode: null, signal: 'SIGABRT' }, + { exitCode: 23, signal: 'SIGABRT' }, + { exitCode: null, signal: null }, + ] + for (const outcome of outcomes) { + const child = fakeChild() + async function* stream(): AsyncGenerator { + child.settle(outcome) + await Promise.resolve() + throw new Error('SECRET_TOKEN from process transport') + } + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return Object.assign(stream(), { close: vi.fn() }) as unknown as Query + }) + const run = await startClaudeCodeRun(request(), { + cwd: '/workspace', + executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }) + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic( + 'process', + 'process-exit', + outcome, + ), + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + await run.dispose() + } + }) + it('gives local cancellation precedence and isolates overlapping controllers', async () => { const firstChild = fakeChild() const secondChild = fakeChild() @@ -974,7 +1072,7 @@ describe('run publication, cancellation, and settlement', () => { ) await expect(startClaudeCodeRun(request(), { ...unused.spec, - })).rejects.toThrow('did not publish a controllable') + })).rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) expect(noChildClose).toHaveBeenCalledOnce() const closeFailure = vi.fn(() => { throw new Error('close boom') }) @@ -984,6 +1082,8 @@ describe('run publication, cancellation, and settlement', () => { const noChild = startClaudeCodeRun(request(), { ...unused.spec, }) + await expect(noChild) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) await expect(noChild).rejects.toBeInstanceOf(AggregateError) const startupAbort = new AbortController() @@ -1006,12 +1106,40 @@ describe('run publication, cancellation, and settlement', () => { expect(abortedClose).toHaveBeenCalledOnce() expect(abortedChild.terminate).toHaveBeenCalledOnce() + const cleanupAbort = new AbortController() + const cleanupFailedChild = fakeChild({ + waitForExitError: new Error('SECRET_TOKEN cleanup wait failure'), + }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + cleanupAbort.abort(new Error('startup cancelled')) + return queryFrom([]) + }) + const cancelledCleanupFailure = startClaudeCodeRun( + request(undefined, cleanupAbort.signal), + { + ...unused.spec, + spawn: () => cleanupFailedChild.handle, + }, + ) + await expect(cancelledCleanupFailure) + .rejects.toBeInstanceOf(AggregateError) + await expect(cancelledCleanupFailure) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(cancelledCleanupFailure) + .rejects.not.toThrow('SECRET_TOKEN') + queryMock.mockImplementationOnce(() => { throw new Error('query failed before resource creation') }) - await expect(startClaudeCodeRun(request(), { + const queryFailure = startClaudeCodeRun(request(), { ...unused.spec, - })).rejects.toThrow('query failed before resource creation') + }) + await expect(queryFailure) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(queryFailure).rejects.not.toThrow( + 'query failed before resource creation', + ) const spawned = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] @@ -1019,6 +1147,7 @@ describe('run publication, cancellation, and settlement', () => { queryMock.mockImplementationOnce(({ options }) => { factoryController = options.abortController options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + spawned.settle({ exitCode: 17, signal: 'SIGABRT' }) throw new Error('query construction failed') }) const factoryFailure = startClaudeCodeRun(request(), { @@ -1028,7 +1157,12 @@ describe('run publication, cancellation, and settlement', () => { return spawned.handle }, }) - await expect(factoryFailure).rejects.toThrow('query construction failed') + await expect(factoryFailure).rejects.toThrow(expectedFailureDiagnostic( + 'query-start', + 'unknown', + { exitCode: 17, signal: 'SIGABRT' }, + )) + await expect(factoryFailure).rejects.not.toThrow('query construction failed') expect(spawnSpecs).toHaveLength(1) expect(factoryController?.signal.aborted).toBe(true) expect(spawned.terminate).toHaveBeenCalledOnce() @@ -1038,8 +1172,10 @@ describe('run publication, cancellation, and settlement', () => { doneError: new Error('spawn failed'), }) const failed = fakeRun([], undefined, failedSpawn) - await expect(startClaudeCodeRun(request(), failed.spec)) - .rejects.toBeInstanceOf(AggregateError) + const failedStartup = startClaudeCodeRun(request(), failed.spec) + await expect(failedStartup) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(failedStartup).rejects.toBeInstanceOf(AggregateError) expect(failed.close).toHaveBeenCalledOnce() }) }) @@ -1080,10 +1216,16 @@ describe('query and process disposal', () => { waitForExitError: new Error('wait boom'), }) const closeFailure = vi.fn(() => { throw new Error('close boom') }) - await expect(disposeClaudeCodeChild( + const waitAndClose = disposeClaudeCodeChild( { close: closeFailure }, waitFailure.handle, - )).rejects.toBeInstanceOf(AggregateError) + ) + await expect(waitAndClose).rejects.toThrow(expectedFailureDiagnostic( + 'teardown', + 'unknown', + { exitCode: 0, signal: null }, + )) + await expect(waitAndClose).rejects.toBeInstanceOf(AggregateError) expect(waitFailure.terminate).toHaveBeenCalledOnce() const doneFailure = fakeChild({ @@ -1093,15 +1235,18 @@ describe('query and process disposal', () => { await expect(disposeClaudeCodeChild( { close: vi.fn() }, doneFailure.handle, - )).rejects.toThrow('spawn boom') + )).rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) const both = fakeChild({ pid: -1, doneError: new Error('spawn boom'), }) - await expect(disposeClaudeCodeChild( + const bothFailures = disposeClaudeCodeChild( { close: () => { throw new Error('close boom') } }, both.handle, - )).rejects.toBeInstanceOf(AggregateError) + ) + await expect(bothFailures) + .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) + await expect(bothFailures).rejects.toBeInstanceOf(AggregateError) }) }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 6d136cf2d0..acbb09f49f 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 161159264ffadf32cc769d65f19caf6d74dc862d -README.zh.md: 561206ae56684329ca54b1a524b224a73e4f30b3 +README.md: f976466610f37a4744c5a2f1e2fff590ea7587b9 +README.zh.md: bcd4e4cc7f1dc56db7f278f4ff32c148d1d6e872 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 161159264f..f976466610 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -64,7 +64,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. -`SubagentRun.result` resolves to `{ output, structured?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal result contract). +`SubagentRun.result` resolves to `{ output, structured?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The common result type does not define provider categories or lifecycle stages: an out-of-process provider may derive fixed display text from its version-pinned structured product facts and an observed process outcome, while consumers render that text without parsing it. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal result contract). A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 561206ae56..bcd4e4cc7f 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -64,7 +64,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且在任何失败路径上都必须取消、回滚并使尚未发布的资源完全停稳。兑现后,run 的所有权转移给调用方;调用方必须在每条路径上调用 `dispose()`。剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。共享结果类型不定义提供方类别或生命周期阶段:进程外提供方可以从锁定版本产品提供的结构化事实与已观测的进程结果派生固定展示文本,而消费方只负责原样呈现,不解析该文本。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 From 605b399a8f7de3a7f475dc47f434c2439ce8ff3b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 02:45:42 +0800 Subject: [PATCH 11/56] refactor(subagent): simplify Claude failure fact ownership --- .../subagent-claude-code/src/process.ts | 8 +++ .../subagent/subagent-claude-code/src/run.ts | 57 +++++++++++-------- .../tests/subagent-claude-code.spec.ts | 21 +++++++ 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 1e2a259ca2..13f5eb132c 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -14,6 +14,7 @@ import type { import { scrubbedParentEnv, type SubprocessHandle, + type SubprocessOutcome, type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' @@ -83,6 +84,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { private readonly events = new EventEmitter() private exitCodeValue: number | null = null private signalCodeValue: NodeJS.Signals | null = null + private outcomeValue: SubprocessOutcome | undefined private killRequested = false /** @@ -98,6 +100,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { this.events.on('error', () => {}) void child.done.then( (outcome) => { + this.outcomeValue = outcome this.exitCodeValue = outcome.exitCode this.signalCodeValue = outcome.signal this.events.emit('exit', outcome.exitCode, outcome.signal) @@ -123,6 +126,11 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { return this.signalCodeValue } + /** Exact managed-process outcome after exit, or undefined while running. */ + get outcome(): SubprocessOutcome | undefined { + return this.outcomeValue + } + /** * Route the SDK's termination request to the tree-scoped process owner. * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder. diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 3e5b3c0905..8fc38e5358 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -168,10 +168,6 @@ function thrown(value: unknown): Error { /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) } - -function isAborted(signal: AbortSignal): boolean { - return signal.aborted -} /* jscpd:ignore-end */ /** @@ -308,14 +304,17 @@ export async function disposeClaudeCodeChild( * Build the fixed official SDK options for one one-shot provider run. * @param spec - Workspace, environment, process service, and disposal policy. * @param controller - per-run cancellation owner. - * @param capture - receives the real managed child synchronously from the SDK hook. + * @param capture - receives the shared child and SDK-facing process synchronously. * @param captureDiagnostic - receives safe facts from unattended interaction callbacks. * @returns options that inherit native settings while disabling persistence and user questions. */ export function claudeQueryOptions( spec: ClaudeCodeRunSpec, controller: AbortController, - capture: (child: SubprocessHandle) => void, + capture: ( + child: SubprocessHandle, + process: ManagedClaudeCodeProcess, + ) => void, captureDiagnostic: (diagnostic: string) => void, ): Options { return { @@ -365,8 +364,9 @@ export function claudeQueryOptions( supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS, spawnClaudeCodeProcess: (options: SpawnOptions) => { const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs)) - capture(child) - return new ManagedClaudeCodeProcess(child) + const process = new ManagedClaudeCodeProcess(child) + capture(child, process) + return process }, } } @@ -397,21 +397,23 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let query: Query | undefined - let processOutcome: SubprocessOutcome | undefined - let failureDetail: string | undefined - let permissionDetail: string | undefined + let managedProcess: ManagedClaudeCodeProcess | undefined + let diagnostic: string | undefined const capturePermissionDiagnostic = (value: string): void => { - permissionDetail = value + diagnostic = value } - const collectDiagnostic = (): string => [failureDetail, permissionDetail] - .filter((value): value is string => value !== undefined) - .join('\n') - const captureChild = (captured: SubprocessHandle): void => { + const prependFailureDiagnostic = (facts: ClaudeCodeFailureFacts): void => { + const failure = failureDiagnostic(facts) + diagnostic = diagnostic === undefined + ? failure + : `${failure}\n${diagnostic}` + } + const captureChild = ( + captured: SubprocessHandle, + process: ManagedClaudeCodeProcess, + ): void => { child = captured - void captured.done.then( - (outcome: SubprocessOutcome) => { processOutcome = outcome }, - () => undefined, - ) + managedProcess = process } try { query = officialQuery({ @@ -435,7 +437,7 @@ export async function startClaudeCodeRun( request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = controller.signal.aborted await Promise.resolve() - const startupOutcome = processOutcome + const startupOutcome = managedProcess?.outcome const startupFacts = { stage: 'query-start', category: 'unknown', @@ -473,7 +475,12 @@ export async function startClaudeCodeRun( ) } } - if (cancelledBeforeCleanup || isAborted(request.signal)) { + if (cancelledBeforeCleanup) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + try { + request.signal.throwIfAborted() + } catch { throw new Error('subagent-claude-code: request was aborted before SDK startup') } throw startupFailure() @@ -493,7 +500,7 @@ export async function startClaudeCodeRun( )) }) } catch (error: unknown) { - await Promise.resolve() + const processOutcome = managedProcess?.outcome const facts = error instanceof ClaudeCodeFailure ? { ...error.facts, outcome: processOutcome } : processOutcome === undefined @@ -503,14 +510,14 @@ export async function startClaudeCodeRun( category: 'process-exit', outcome: processOutcome, } as const - failureDetail = failureDiagnostic(facts) + prependFailureDiagnostic(facts) throw error instanceof ClaudeCodeFailure ? error : new ClaudeCodeFailure(facts, thrown(error)) } }, collectOutput: () => [], - collectDiagnostic, + collectDiagnostic: () => diagnostic, cancelled: () => controller.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 372ed6269b..7325456afd 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -553,6 +553,7 @@ describe('official spawn projection', () => { expect(process.killed).toBe(false) expect(process.exitCode).toBeNull() expect(process.signalCode).toBeNull() + expect(process.outcome).toBeUndefined() const exit = vi.fn() const once = vi.fn() @@ -572,6 +573,7 @@ describe('official spawn projection', () => { expect(once).toHaveBeenCalledOnce() expect(removed).not.toHaveBeenCalled() expect(process.signalCode).toBe('SIGTERM') + expect(process.outcome).toEqual({ exitCode: null, signal: 'SIGTERM' }) expect(process.kill('SIGTERM')).toBe(false) }) @@ -598,6 +600,7 @@ describe('official spawn projection', () => { await nextTask() expect(process.exitCode).toBe(7) expect(process.signalCode).toBeNull() + expect(process.outcome).toEqual({ exitCode: 7, signal: null }) expect(process.kill('SIGTERM')).toBe(false) }) }) @@ -1167,6 +1170,24 @@ describe('run publication, cancellation, and settlement', () => { expect(factoryController?.signal.aborted).toBe(true) expect(spawned.terminate).toHaveBeenCalledOnce() + const cleanupRaceAbort = new AbortController() + const cleanupRaceChild = fakeChild({ exitOnTerminate: false }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw new Error('query failed before cleanup wait') + }) + const cleanupRace = startClaudeCodeRun( + request(undefined, cleanupRaceAbort.signal), + { + ...unused.spec, + spawn: () => cleanupRaceChild.handle, + }, + ) + await nextTask() + cleanupRaceAbort.abort(new Error('cancelled during cleanup')) + cleanupRaceChild.settle() + await expect(cleanupRace).rejects.toThrow('aborted before SDK startup') + const failedSpawn = fakeChild({ pid: -1, doneError: new Error('spawn failed'), From a3a9f86d8968876020bd597ea5e39db63525065f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 02:54:20 +0800 Subject: [PATCH 12/56] refactor(subagent): derive Claude exit projections --- packages/subagent/subagent-claude-code/src/process.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 13f5eb132c..cba2f61441 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -82,8 +82,6 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { readonly stdin readonly stdout private readonly events = new EventEmitter() - private exitCodeValue: number | null = null - private signalCodeValue: NodeJS.Signals | null = null private outcomeValue: SubprocessOutcome | undefined private killRequested = false @@ -101,8 +99,6 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { void child.done.then( (outcome) => { this.outcomeValue = outcome - this.exitCodeValue = outcome.exitCode - this.signalCodeValue = outcome.signal this.events.emit('exit', outcome.exitCode, outcome.signal) }, (error: unknown) => { @@ -118,12 +114,12 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { /** Direct-child exit code, or null while running or after signal exit. */ get exitCode(): number | null { - return this.exitCodeValue + return this.outcomeValue?.exitCode ?? null } /** Direct-child terminating signal, if any. */ get signalCode(): NodeJS.Signals | null { - return this.signalCodeValue + return this.outcomeValue?.signal ?? null } /** Exact managed-process outcome after exit, or undefined while running. */ @@ -139,8 +135,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { kill(_signal: NodeJS.Signals): boolean { if ( this.killRequested - || this.exitCodeValue !== null - || this.signalCodeValue !== null + || this.outcomeValue !== undefined ) { return false } From 77211b1c262b0bdfe147bfa2ed67aea958782f8e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 03:06:34 +0800 Subject: [PATCH 13/56] fix(subagent): expose startup cleanup failure facts --- .../subagent/subagent-claude-code/src/run.ts | 19 +++++++++---------- .../tests/subagent-claude-code.spec.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 8fc38e5358..2604e59397 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -453,9 +453,10 @@ export async function startClaudeCodeRun( await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { const failure = startupFailure() + const cleanupFailure = thrown(disposeError) throw new AggregateError( - [failure, thrown(disposeError)], - `${failure.message}; startup cleanup also failed`, + [failure, cleanupFailure], + `${failure.message}; ${cleanupFailure.message}`, ) } } else if (query !== undefined) { @@ -463,15 +464,13 @@ export async function startClaudeCodeRun( query.close() } catch (disposeError: unknown) { const failure = startupFailure() + const cleanupFailure = new ClaudeCodeFailure({ + stage: 'teardown', + category: 'unknown', + }, thrown(disposeError)) throw new AggregateError( - [ - failure, - new ClaudeCodeFailure({ - stage: 'teardown', - category: 'unknown', - }, thrown(disposeError)), - ], - `${failure.message}; startup cleanup also failed`, + [failure, cleanupFailure], + `${failure.message}; ${cleanupFailure.message}`, ) } } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 7325456afd..786f19d37c 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1087,6 +1087,9 @@ describe('run publication, cancellation, and settlement', () => { }) await expect(noChild) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(noChild).rejects.toThrow( + `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + ) await expect(noChild).rejects.toBeInstanceOf(AggregateError) const startupAbort = new AbortController() @@ -1129,6 +1132,9 @@ describe('run publication, cancellation, and settlement', () => { .rejects.toBeInstanceOf(AggregateError) await expect(cancelledCleanupFailure) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(cancelledCleanupFailure).rejects.toThrow( + `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown', { exitCode: 0, signal: null })}`, + ) await expect(cancelledCleanupFailure) .rejects.not.toThrow('SECRET_TOKEN') @@ -1196,6 +1202,9 @@ describe('run publication, cancellation, and settlement', () => { const failedStartup = startClaudeCodeRun(request(), failed.spec) await expect(failedStartup) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(failedStartup).rejects.toThrow( + `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + ) await expect(failedStartup).rejects.toBeInstanceOf(AggregateError) expect(failed.close).toHaveBeenCalledOnce() }) From fded16f68881407c261b87480a4cdb7500c73a1a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 03:33:58 +0800 Subject: [PATCH 14/56] refactor(subagent): use parent abort as startup authority --- .../subagent/subagent-claude-code/src/run.ts | 4 -- .../tests/real-product.spec.ts | 37 ++++++++++++------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 2604e59397..5ea41d9bac 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -435,7 +435,6 @@ export async function startClaudeCodeRun( } } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) - const cancelledBeforeCleanup = controller.signal.aborted await Promise.resolve() const startupOutcome = managedProcess?.outcome const startupFacts = { @@ -474,9 +473,6 @@ export async function startClaudeCodeRun( ) } } - if (cancelledBeforeCleanup) { - throw new Error('subagent-claude-code: request was aborted before SDK startup') - } try { request.signal.throwIfAborted() } catch { diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 5e55e9c521..344515d4cf 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -227,11 +227,15 @@ async function expectQuiescent( } } -function expectedProcessFailure(outcome: SubprocessOutcome): string { +function expectedFailure( + stage: 'query-run' | 'process', + category: 'error_during_execution' | 'process-exit', + outcome: SubprocessOutcome, +): string { const fields = [ 'product: Claude Code', - 'stage: process', - 'category: process-exit', + `stage: ${stage}`, + `category: ${category}`, ] if (outcome.exitCode !== null) fields.push(`exit code: ${outcome.exitCode}`) if (outcome.signal !== null) fields.push(`signal: ${outcome.signal}`) @@ -356,11 +360,13 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 expect(harness.handles).toHaveLength(1) harness.handles[0]!.terminate() const outcome = await harness.handles[0]!.done - await expect(run.result).resolves.toEqual({ - output: [], - diagnostic: expectedProcessFailure(outcome), - stopReason: 'error', - }) + const result = await run.result + expect(result.output).toEqual([]) + expect(result.stopReason).toBe('error') + expect([ + expectedFailure('process', 'process-exit', outcome), + expectedFailure('query-run', 'error_during_execution', outcome), + ]).toContain(result.diagnostic) await run.dispose() expect(fixture.requests).toHaveLength(1) expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey) @@ -389,11 +395,16 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 harness.handles[0]!.terminate() const outcome = await harness.handles[0]!.done const result = await run.result - expect(result).toEqual({ - output: [], - diagnostic: `${expectedProcessFailure(outcome)}\nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt`, - stopReason: 'error', - }) + expect(result.output).toEqual([]) + expect(result.stopReason).toBe('error') + const diagnosticLines = result.diagnostic?.split('\n') ?? [] + expect([ + expectedFailure('process', 'process-exit', outcome), + expectedFailure('query-run', 'error_during_execution', outcome), + ]).toContain(diagnosticLines[0]) + expect(diagnosticLines[1]).toBe( + 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + ) expect(result.diagnostic).not.toContain(target) expect(result.diagnostic).not.toContain('SECRET_TOKEN') await run.dispose() From 6bae03dd692d777c84552e1d21ed01185dc9a582 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 03:40:11 +0800 Subject: [PATCH 15/56] fix(subagent): preserve Codex failure facts --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 6 +- ...ude-code-and-codex-subagent-backends.zh.md | 6 +- ...agent-noninteractive-permissions.i18n.yaml | 4 +- ...uct-subagent-noninteractive-permissions.md | 4 +- ...-subagent-noninteractive-permissions.zh.md | 4 +- ...8-product-subagent-failure-facts.i18n.yaml | 4 +- ...26-08-18-product-subagent-failure-facts.md | 23 +- ...08-18-product-subagent-failure-facts.zh.md | 23 +- .../fixtures/subagent-result-diagnostic.ts | 38 +- .../input.json | 2 +- .../replay.override.json | 42 +- .../session.jsonl | 89 ++-- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 12 +- packages/subagent/subagent-codex/README.zh.md | 12 +- packages/subagent/subagent-codex/src/run.ts | 198 +++++++-- packages/subagent/subagent-codex/src/wire.ts | 159 +++++-- .../subagent-codex/tests/real-product.spec.ts | 92 +++- .../tests/subagent-codex.spec.ts | 412 ++++++++++++++++-- 20 files changed, 956 insertions(+), 182 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 9c2c6dd814..a3f77cb935 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 829dca8dbd79b408fcfcfd1d88490d793ad4b5ee -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 063bd8c9a59a1b1eccf4893a00efe12a80fbe03f +2026-08-04-claude-code-and-codex-subagent-backends.md: a75c8c50144ca71190fc8ad16f623b062bd3f492 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 21e05fb8ede1a7e2a58182687648e9513a634d96 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 829dca8dbd..a75c8c5014 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -38,11 +38,11 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; a permission-related error may additionally carry the shared safe diagnostic. This version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted` without permission detail. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The Provider preserves every Codex 0.147.0 string and object `codexErrorInfo` variant in the shared safe diagnostic, including numeric HTTP status on the four connection/stream variants while omitting `turnKind`. It derives `initialize`, `thread-start`, `turn-start`, `turn`, `process`, and `teardown` from the current operation, preserves available exit code and signal, and uses `unknown` outside the fixed schema. `contextWindowExceeded` remains `max-tokens`; every other category remains `error`, `cyberPolicy` does not become `refusal`, and local cancellation remains `aborted` without a failure diagnostic. For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. -An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Result failure and teardown failure stay independently observable. +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal. Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. @@ -62,7 +62,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies both fixed one-shot tools expose optional background scheduling alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. +The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Generated schema evidence and package tests pin all sixteen error-info variants, HTTP status, six stages, process outcomes, stop-reason mapping, unknown fallback, sanitization, permission ordering, cancellation, concurrency, and cleanup aggregation. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, a real `internalServerError`, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, process/protocol failure, and whole-tree exit. Production still supplies `codex` on `PATH`. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 063bd8c9a5..21e05fb8ed 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -38,11 +38,11 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;权限相关错误可以额外携带共享安全诊断。本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`,且不附带权限说明。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。提供方会把 Codex 0.147.0 的每种字符串与对象 `codexErrorInfo` variant 保留进共享安全诊断;四种连接/stream variant 会保留数值 HTTP status,但不会公开 `turnKind`。它会从当前操作派生 `initialize`、`thread-start`、`turn-start`、`turn`、`process` 和 `teardown`,保留可用的退出码与信号,并对固定 schema 之外的值使用 `unknown`。`contextWindowExceeded` 仍是 `max-tokens`;其他类别仍是 `error`,`cyberPolicy` 不会成为 `refusal`,本地取消仍是 `aborted` 且不附带失败诊断。 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 -若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。结果失败与清理失败仍可彼此独立地观察。 +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。 Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 @@ -62,7 +62,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,在同一个上下文中验证两个固定一次性工具会与通用 Job 控制工具一起公开可选后台调度,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。生成的 schema 证据与包测试固定全部十六种 error-info variant、HTTP status、六个阶段、进程结果、终止原因映射、unknown 回退、脱敏、权限顺序、取消、并发与清理聚合。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、真实 `internalServerError`、测试拥有临时存储中的显式危险绕过写入、本地取消、进程/协议失败以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 6b3e5d13ff..ac0970f3be 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: 9327412cfdd306f7867f989c8cfc091941cb26e6 -2026-08-15-product-subagent-noninteractive-permissions.zh.md: a7992b58a14aff94f93397bfa0aa21b9fe727fb2 +2026-08-15-product-subagent-noninteractive-permissions.md: 8788fba3492e08090dd038fc3e7377f6bd1e29cd +2026-08-15-product-subagent-noninteractive-permissions.zh.md: 6f254930151abce23f04de4f354bf57ad81bba61 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index 9327412cfd..8788fba349 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -46,7 +46,7 @@ The Provider overrides only those thread fields. `CODEX_HOME`, project configura `SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns non-permission product categories, lifecycle stages, and process outcomes carried by the same field. -Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Claude Code places its structured failure line before the latest contributing permission fact; Codex retains its permission-only diagnostic in this product version. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`. +Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Both Providers place their structured failure line before the latest contributing permission fact. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`. The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior. @@ -63,7 +63,7 @@ The foreground consumer presents the stop-reason headline, then the optional dia ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared foreground and Job diagnostic presentation while the model-facing product tool schemas contain no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index a7992b58a1..6f25493015 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -46,7 +46,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 `SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责由同一字段承载的非权限产品类别、生命周期阶段与进程结果。 -每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。Claude Code 会把结构化失败行放在最新参与失败的权限事实之前;当前产品版本中的 Codex 仍只生成权限诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 +每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。两个提供方都会把结构化失败行放在最新参与失败的权限事实之前。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。 @@ -63,7 +63,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录前台与 Job 共享的诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml index b27044310b..d7652c90f3 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md -2026-08-18-product-subagent-failure-facts.md: 4380e36d172f395692d2d84c97d45cba95701f9f -2026-08-18-product-subagent-failure-facts.zh.md: d601becdf14bd74ae871a66d4798ffe9c49b6490 +2026-08-18-product-subagent-failure-facts.md: 069c9114fd819fafa8872f58d3031acda28e64dd +2026-08-18-product-subagent-failure-facts.zh.md: 5d794cd12f1f7b8079739d4d119e1607471c22af diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md index 4380e36d17..069c9114fd 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md @@ -19,10 +19,10 @@ Each product Provider owns the mapping from its pinned official error union, cur The structured line has this fixed order: ```text -Product subagent failure (product: ; stage: ; category: ; exit code: ; signal: ) +Product subagent failure (product: ; stage: ; category: ; HTTP status: ; exit code: ; signal: ) ``` -The Provider omits unavailable exit fields. Exit code and signal are independent facts and are each retained when observed. A contributing permission decision from the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) follows the structured line; the latest safe permission fact remains operation-local. The shared result boundary limits the complete text to 4096 UTF-8 bytes. +The Provider omits unavailable optional fields. Exit code and signal are independent facts and are each retained when observed. A contributing permission decision from the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) follows the structured line; the latest safe permission fact remains operation-local. The shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful results and local cancellation expose no failure fact. Raw product errors, stderr, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic. Startup and cleanup rejections use the same safe line in their Error message while retaining the original failure only on the internal cause chain and in Host logging. @@ -37,7 +37,20 @@ Agent SDK 0.3.220 defines four error subtypes: `error_during_execution`, `error_ | `process` | Managed CLI exits before the SDK supplies a terminal result | The run resolves as `error` with `process-exit` and the available exit code and signal | | `teardown` | Query close and managed process-tree release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | -The Codex Provider retains its existing result mapping: `contextWindowExceeded` is `max-tokens`, other turn failures remain `error`, and permission-related paths may carry their existing safe diagnostic. Other Codex error-info members are not represented as shared categories by this decision's current implementation. +### Codex facts + +Codex app-server 0.147.0 defines eleven string categories and five object variants. The Provider preserves `contextWindowExceeded`, `sessionBudgetExceeded`, `usageLimitExceeded`, `serverOverloaded`, `cyberPolicy`, `internalServerError`, `unauthorized`, `badRequest`, `threadRollbackFailed`, `sandboxError`, and `other`. It also preserves `httpConnectionFailed`, `responseStreamConnectionFailed`, `responseStreamDisconnected`, `responseTooManyFailedAttempts`, and `activeTurnNotSteerable`; the four connection/stream variants retain numeric `httpStatusCode`, while the active-turn variant does not expose `turnKind`. Unknown strings, objects with another variant set, malformed values, and unclassified exceptions use `unknown`. + +| Stage | Owned operation | Observable failure | +| --- | --- | --- | +| `initialize` | App-server spawn and initialize/initialized handshake | `start()` rejects with fixed safe facts and any process outcome already observed | +| `thread-start` | Ephemeral `thread/start` request and response validation | `start()` rejects with the thread stage and any available process outcome | +| `turn-start` | Published `turn/start` request, provisional ids, and early frames | The run resolves as `error` with a safe unknown fallback when no structured category exists | +| `turn` | Terminal notification, final-answer selection, and error-info mapping | The complete category and optional HTTP status reach the non-completed result | +| `process` | Managed app-server exits before another terminal path settles | The run resolves as `error` with `process-exit` and any available code and signal | +| `teardown` | Wire close and process-tree release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines | + +`contextWindowExceeded` remains `max-tokens`; every other known or unknown Codex category remains `error`, and `cyberPolicy` does not become `refusal`. ### Ownership and lifecycle @@ -51,7 +64,7 @@ The Codex Provider retains its existing result mapping: `contextWindowExceeded` ## Verification -Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. The real SDK/CLI fixture produces an actual `error_max_turns` result and an actual early process exit while proving whole-tree quiescence. The keyless ACP snapshot records the same failure diagnostic in foreground error output, the background completion notice, and `job_output`. +Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and whole-tree quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`. ## Alternatives considered @@ -67,7 +80,7 @@ Claude Code package tests pin all four SDK subtypes, invalid success, missing re ## Consequences -The parent can distinguish important Claude Code product limits, invalid terminal results, unknown query failures, and early process exits without receiving raw product text. Foreground and background scheduling preserve the same fact because both consume one `SubagentResult`. +The parent can distinguish important Claude Code limits and Codex budget, usage, service, policy, request, connection, stream, rollback, sandbox, and active-turn failures without receiving raw product text. Foreground and background scheduling preserve the same fact because both consume one `SubagentResult`. The diagnostic is display text rather than a new public protocol. Callers may present it but must not branch on its punctuation or product-private category names. A pinned product-version upgrade must update the Provider mapping and evidence when its official error union changes. diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md index d601becdf1..5d794cd12f 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md @@ -19,10 +19,10 @@ Status: implemented 结构化行采用以下固定顺序: ```text -Product subagent failure (product: ; stage: ; category: ; exit code: ; signal: ) +Product subagent failure (product: ; stage: ; category: ; HTTP status: ; exit code: ; signal: ) ``` -提供方会省略不可用的退出字段。退出码与信号是相互独立的事实,只要已观测到就分别保留。来自[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)且参与失败的权限决定会跟在结构化行之后;最新的安全权限事实仍只属于当前操作。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。 +提供方会省略不可用的可选字段。退出码与信号是相互独立的事实,只要已观测到就分别保留。来自[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)且参与失败的权限决定会跟在结构化行之后;最新的安全权限事实仍只属于当前操作。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。 成功结果与本地取消都不公开失败事实。原始产品错误、stderr、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。启动与清理拒绝会在 Error 消息中使用同一安全行,而原始失败只保留在内部 cause 链与 Host 日志中。 @@ -37,7 +37,20 @@ Agent SDK 0.3.220 定义四种错误子类型:`error_during_execution`、`erro | `process` | SDK 提供终态结果之前受管 CLI 已退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码和信号 | | `teardown` | Query 关闭与受管进程树释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | -Codex 提供方保留既有结果映射:`contextWindowExceeded` 是 `max-tokens`,其他轮次失败仍是 `error`,权限相关路径可以携带既有安全诊断。本决策的当前实现不会把其他 Codex error-info 成员表示为共享类别。 +### Codex 事实 + +Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant。提供方会保留 `contextWindowExceeded`、`sessionBudgetExceeded`、`usageLimitExceeded`、`serverOverloaded`、`cyberPolicy`、`internalServerError`、`unauthorized`、`badRequest`、`threadRollbackFailed`、`sandboxError` 和 `other`。它还会保留 `httpConnectionFailed`、`responseStreamConnectionFailed`、`responseStreamDisconnected`、`responseTooManyFailedAttempts` 与 `activeTurnNotSteerable`;四种连接/stream variant 会保留数值 `httpStatusCode`,而 active-turn variant 不公开 `turnKind`。未知字符串、同时含其他 variant 的对象、格式错误值与未分类异常统一使用 `unknown`。 + +| 阶段 | 归属操作 | 可观察失败 | +| --- | --- | --- | +| `initialize` | App-server spawn 与 initialize/initialized 握手 | `start()` 以固定安全事实和已经观测到的进程结果拒绝 | +| `thread-start` | 临时 `thread/start` 请求与响应校验 | `start()` 以线程阶段和可用进程结果拒绝 | +| `turn-start` | 已发布 `turn/start` 请求、暂定 id 与早到 frame | 没有结构化类别时,运行以 `error` 和安全 unknown 回退兑现 | +| `turn` | 终态通知、最终答案选择与 error-info 映射 | 完整类别与可选 HTTP status 进入非完成结果 | +| `process` | 受管 app-server 在另一终态路径结算前退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码与信号 | +| `teardown` | Wire 关闭与进程树释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 | + +`contextWindowExceeded` 仍是 `max-tokens`;其他所有已知或未知 Codex 类别仍是 `error`,`cyberPolicy` 不会变成 `refusal`。 ### 所有权与生命周期 @@ -51,7 +64,7 @@ Codex 提供方保留既有结果映射:`contextWindowExceeded` 是 `max-token ## Verification -Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。真实 SDK/CLI fixture 会产生真实的 `error_max_turns` 结果与真实的进程提前退出,并证明整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录同一份失败诊断。 +Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。 ## Alternatives considered @@ -67,7 +80,7 @@ Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、 ## Consequences -父 agent 可以区分重要的 Claude Code 产品限制、无效终态结果、未知 query 失败和进程提前退出,而不会收到原始产品文本。前台与后台调度会保留同一事实,因为二者都消费同一个 `SubagentResult`。 +父 agent 可以区分重要的 Claude Code 限制,以及 Codex 预算、用量、服务、策略、请求、连接、stream、回滚、sandbox 和 active-turn 失败,而不会收到原始产品文本。前台与后台调度会保留同一事实,因为二者都消费同一个 `SubagentResult`。 诊断只是展示文本,不是新的公开协议。调用方可以呈现它,但不得根据其标点或产品私有类别名称进行分支。锁定产品版本升级并改变官方错误联合时,必须同步更新提供方映射与证据。 diff --git a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts index 56626802a4..314f1e9f74 100644 --- a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts +++ b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts @@ -11,7 +11,28 @@ import { SessionId } from '@deepseek-ai/dsh-session' export const name = 'subagent-result-diagnostic' export const inject = ['subagents'] -const DIAGNOSTIC = 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)' +const RESULTS = [ + { + id: '00000000-0000-4000-8000-0000000000d1', + diagnostic: 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)', + output: [{ type: 'text' as const, text: 'partial assistant text' }], + }, + { + id: '00000000-0000-4000-8000-0000000000d2', + diagnostic: 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)', + output: [], + }, + { + id: '00000000-0000-4000-8000-0000000000d3', + diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)', + output: [{ type: 'text' as const, text: 'partial assistant text' }], + }, + { + id: '00000000-0000-4000-8000-0000000000d4', + diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)', + output: [], + }, +] as const class DiagnosticProvider implements SubagentProvider { readonly name = 'snapshot-diagnostic' @@ -24,19 +45,16 @@ class DiagnosticProvider implements SubagentProvider { throw new Error('snapshot diagnostic provider start aborted') } const index = this.starts++ - if (index > 1) { - throw new Error('snapshot diagnostic provider expected exactly two starts') + const fixture = RESULTS[index] + if (fixture === undefined) { + throw new Error('snapshot diagnostic provider expected exactly four starts') } return { - id: SessionId(index === 0 - ? '00000000-0000-4000-8000-0000000000d1' - : '00000000-0000-4000-8000-0000000000d2'), + id: SessionId(fixture.id), localAgent: undefined, result: Promise.resolve({ - output: index === 0 - ? [{ type: 'text' as const, text: 'partial assistant text' }] - : [], - diagnostic: DIAGNOSTIC, + output: [...fixture.output], + diagnostic: fixture.diagnostic, stopReason: 'error' as const, }), dispose: async () => {}, diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json index b75f1d9580..7d29e8fa61 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." } + { "op": "prompt", "text": "Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." } ] } diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json index 6fbff83b8c..f81eb48573 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json @@ -3,8 +3,8 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" } }, + { "type": "tool-call-delta", "index": 0, "id": "call_claude_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}" } }, { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, { "type": "finish", "reason": { "kind": "tool-calls" } } ] @@ -13,8 +13,8 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" } }, + { "type": "tool-call-delta", "index": 0, "id": "call_claude_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}" } }, { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, { "type": "finish", "reason": { "kind": "tool-calls" } } ] @@ -23,8 +23,38 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } }, + { "type": "tool-call-delta", "index": 0, "id": "call_claude_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_codex_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_codex_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_codex_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-2\",\"wait\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-2\",\"wait\":true}" } }, { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, { "type": "finish", "reason": { "kind": "tool-calls" } } ] diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl index b9a725162e..6d5f0ce330 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl @@ -1,51 +1,84 @@ {"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"eb9f20a0-9eac-480c-9904-71a1ffbb742a"}]}} {"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"eb9f20a0-9eac-480c-9904-71a1ffbb742a"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Use subagent_codex in the foreground","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Observe four diagnostic failures with","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} {"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}} -{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}} +{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}} +{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}}} {"type":"assistant/chunk","seq":12,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"92e33995-2f02-4ad5-aec1-9df82cf4d583"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}} -{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"f63d21ab-ccdc-44f2-9a96-4c60b46e5318"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"3cc2d0b5-97a5-4685-af60-ed7f7db8f69a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}} +{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_claude_foreground"},"content":[{"type":"tool-result","toolCallId":"call_claude_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"8743817e-158e-45cb-88d9-a695b2653eca"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1786781990613,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1786781990618,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}} -{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}}} {"type":"assistant/chunk","seq":22,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"2fb444e2-7a52-4963-988e-b1ecbc3744d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}} -{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Claude Co…"},"role":"user","id":"05f93dde-37e9-40e7-92d5-f8de526a8bee"}]}} -{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_diagnostic_background"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"3377f724-b4a7-4ce1-bed7-774f174917d6"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b504312a-1dc5-46ce-87a5-12a5817511b9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}} +{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe Claude background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Claude background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cl…"},"role":"user","id":"0fdb9ddf-1657-4455-9941-e6a9daa8ae4a"}]}} +{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_claude_background"},"content":[{"type":"tool-result","toolCallId":"call_claude_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"fe60646b-0551-4703-aa03-c8cb5460d356"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1786781990627,"data":{"turn":1,"step":2}} {"type":"agent/inbox/spliced","seq":29,"time":1786781990627,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":30,"time":1786781990632,"data":{"turn":1,"step":3}} -{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Claude Co…"},"role":"user","id":"05f93dde-37e9-40e7-92d5-f8de526a8bee"},"surfaceOp":"append"} +{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe Claude background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Claude background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cl…"},"role":"user","id":"0fdb9ddf-1657-4455-9941-e6a9daa8ae4a"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} -{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}} {"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f43f988b-bc08-4811-8671-8edc0613f0d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} -{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} -{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]"}],"isError":false}],"role":"user","id":"10ac5635-c519-4f69-ab5a-cb0f930e9df0"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c48a520a-74ed-42ee-9d93-ee59899975b0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} +{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_claude_output"},"content":[{"type":"tool-result","toolCallId":"call_claude_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]"}],"isError":false}],"role":"user","id":"45bc0705-7243-4173-a119-4c0655af8dc1"}},"sourceEventSeqs":[38],"surfaceOp":"append"} {"type":"step/end","seq":40,"time":1786781990640,"data":{"turn":1,"step":3}} {"type":"step/start","seq":41,"time":1786781990645,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}} -{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}} -{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} -{"type":"step/end","seq":48,"time":1786781990650,"data":{"turn":1,"step":4}} -{"type":"turn/end","seq":49,"time":1786781990650,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}} +{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}}} +{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"89ab3728-fc3f-4825-97e5-383d46568d8c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"tool/call","seq":48,"time":1786994591759,"data":{"turn":1,"step":4,"callId":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}} +{"type":"tool/result","seq":49,"time":1786994591762,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_codex_foreground"},"content":[{"type":"tool-result","toolCallId":"call_codex_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"0a8fd87c-eacb-457b-a5ad-29dd88f599aa"}},"sourceEventSeqs":[48],"surfaceOp":"append"} +{"type":"step/end","seq":50,"time":1786994591762,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":51,"time":1786994591767,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":52,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":53,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":54,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":55,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":56,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1786994591771,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"996da601-acb8-49c9-8dd7-e60a88a8f1a2"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1786994591772,"data":{"turn":1,"step":5,"callId":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}} +{"type":"agent/inbox/spliced","seq":59,"time":1786994591775,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-2 (subagent: Observe Codex background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Codex background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cod…"},"role":"user","id":"5f1d4517-50d4-48ef-8acc-8f9361ecb185"}]}} +{"type":"tool/result","seq":60,"time":1786994591775,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"call_codex_background"},"content":[{"type":"tool-result","toolCallId":"call_codex_background","content":[{"type":"text","text":"started background subagent job subagent-2"}],"isError":false}],"role":"user","id":"a8ba8362-275b-4bca-8b88-d1ef84d325a3"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1786994591776,"data":{"turn":1,"step":5}} +{"type":"agent/inbox/spliced","seq":62,"time":1786994591776,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":63,"time":1786994591781,"data":{"turn":1,"step":6}} +{"type":"user/message","seq":64,"time":1786994591781,"data":{"content":[{"type":"text","text":"background job subagent-2 (subagent: Observe Codex background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Codex background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cod…"},"role":"user","id":"5f1d4517-50d4-48ef-8acc-8f9361ecb185"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":65,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":66,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}} +{"type":"assistant/chunk","seq":67,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}}} +{"type":"assistant/chunk","seq":68,"time":1786994591789,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":69,"time":1786994591789,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":70,"time":1786994591789,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"cfc1726c-5d9e-486a-aa0f-057219e16dfd"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"} +{"type":"tool/call","seq":71,"time":1786994591789,"data":{"turn":1,"step":6,"callId":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}} +{"type":"tool/result","seq":72,"time":1786994591797,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"call_codex_output"},"content":[{"type":"tool-result","toolCallId":"call_codex_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]"}],"isError":false}],"role":"user","id":"9671e5ee-f443-4548-8fd2-b0b76f00b629"}},"sourceEventSeqs":[71],"surfaceOp":"append"} +{"type":"step/end","seq":73,"time":1786994591797,"data":{"turn":1,"step":6}} +{"type":"step/start","seq":74,"time":1786994591802,"data":{"turn":1,"step":7}} +{"type":"assistant/chunk","seq":75,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":76,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}} +{"type":"assistant/chunk","seq":77,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}} +{"type":"assistant/chunk","seq":78,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":79,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":80,"time":1786994591806,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[75,76,77,78,79],"surfaceOp":"append"} +{"type":"step/end","seq":81,"time":1786994591806,"data":{"turn":1,"step":7}} +{"type":"turn/end","seq":82,"time":1786994591807,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 22f8e3c291..96790dec51 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md -README.md: 645479474599eb4cb72c0bf73838a6341c98adb7 -README.zh.md: 1e9d21882b4c84312ea60eff3510bd2295d5334e +README.md: 6ec8287306fa0e9c27bed535a6b8b7311dad9f8b +README.zh.md: f5aadfa89202b5bdc18dec669d64b004848c4556 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 6454794745..6ec8287306 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -6,13 +6,15 @@ This package registers the fixed `codex` subagent provider. Each accepted run st ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. Non-cancellation rejections expose only the fixed `initialize` or `thread-start` stage plus an already observed process outcome; raw product and Host errors remain on internal cause chains. The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. The wire records only the effective mode, request category, decision, and fixed safe reason. It also recognizes declined command/file items and `sandboxError` terminals. Codex 0.147.0 writes some early `never` rejections and sandbox violations only to structured stderr, so the Provider pipes stderr, forwards it unchanged to the host, and matches two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. -Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. A permission-related error may additionally carry the bounded, non-assistant `SubagentResult.diagnostic`; successful and locally cancelled runs omit it. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted`. For failed turns, the diagnostic preserves all eleven string and five object variants in the Codex 0.147.0 `codexErrorInfo` union; the four connection/stream variants retain a numeric `httpStatusCode` when supplied, while `activeTurnNotSteerable` does not expose `turnKind`. The diagnostic also names `turn-start`, `turn`, or `process`, independently includes available exit code and signal, and uses `unknown` for unrecognized or malformed values without copying raw fields. `contextWindowExceeded` remains `max-tokens`; every other remote interruption or failure remains `error`, and the provider produces no `refusal`. A contributing permission decision follows the structured failure line. Successful and locally cancelled runs omit both facts. + +`dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Independent cleanup rejection uses the fixed `teardown` stage and any available process outcome. When startup and rollback both fail, the top-level aggregate message preserves both safe stage lines while the raw failures remain internal. ## Capabilities and context @@ -63,7 +65,7 @@ The standalone composition below shows the complete explicit capability. A Profi ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. Real-product coverage proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, safe diagnostics exclude raw commands and paths, and every wrapper/native process exits. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. Generated schema evidence pins the complete current error union and HTTP-status locations. Real-product coverage proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, a real service failure becomes `internalServerError`, process/protocol failure remains safe and quiescent, raw commands and paths stay out of diagnostics, and every wrapper/native process exits. ## Model Experience @@ -85,7 +87,7 @@ Independent of the parent request cache. Reuse depends only on Codex's own provi #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. The diagnostic can distinguish the fixed error-info category, protocol stage, numeric HTTP status, and observed process outcome without copying product prose. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the same final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session. #### Token effect @@ -98,7 +100,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while ## Known Limitations and Deferred Work - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. -- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. +- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced with its lifecycle stage and the safe `unknown` fallback rather than a separate public taxonomy; the plugin provides no installer, login flow, or runtime version gate. - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy. - **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, commentary, intermediate messages, tool traffic, usage, raw stderr, and workspace diffs remain outside the parent Session, while generic Job ids, notices, and status come from the shared job runtime. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 1e9d21882b..f5aadfa892 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -6,13 +6,15 @@ ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。wire 只记录有效模式、请求类别、决定与固定的安全原因,也会识别被拒绝的命令/文件 item 和 `sandboxError` 终态。Codex 0.147.0 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe stderr、原样转发给 Host,并在每次运行的有界尾缓冲中匹配两个固定签名;原始 stderr 不会进入诊断。 -本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。权限相关错误可以额外携带有界、非 assistant 的 `SubagentResult.diagnostic`;成功和本地取消不会附带它。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`。对于失败轮次,诊断会保留 Codex 0.147.0 `codexErrorInfo` 联合中的全部十一种字符串与五种对象 variant;四种连接/stream variant 会在上游提供时保留数值 `httpStatusCode`,而 `activeTurnNotSteerable` 不公开 `turnKind`。诊断还会注明 `turn-start`、`turn` 或 `process`,分别包含可用的退出码与信号,并对无法识别或格式错误的值使用 `unknown`,且不复制原始字段。`contextWindowExceeded` 仍映射为 `max-tokens`;其他任何远端中断或失败仍映射为 `error`,且该提供方不会产生 `refusal`。参与失败的权限决定会跟在结构化失败行之后。成功与本地取消都不附带这两类事实。 + +`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。独立清理拒绝使用固定的 `teardown` 阶段与可用进程结果。当启动与回滚同时失败时,顶层聚合消息会保留两条安全阶段说明,而原始失败仍只在内部可见。 ## 能力与上下文 @@ -63,7 +65,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。真实产品覆盖会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。 +生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。生成的 schema 证据固定完整的当前错误联合与 HTTP status 所在位置。真实产品覆盖会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,真实服务失败成为 `internalServerError`,进程/协议失败保持安全并完全停稳,诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。 ## 模型体验 @@ -85,7 +87,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。该诊断可以区分固定 error-info 类别、协议阶段、数值 HTTP status 和已观测的进程结果,而不复制产品正文。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开同一最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -98,7 +100,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 ## 已知限制与后续工作 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 -- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 +- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败会公开其生命周期阶段与安全的 `unknown` 回退,而不会增加单独的公开分类体系;本插件不提供安装程序、登录流程或运行时版本门禁。 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。 - **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、过程说明、中间消息、工具通信、用量信息、原始 stderr 和工作区差异不会进入父会话,通用 Job id、通知与状态来自共享作业运行时。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index a75dd4f049..0a0627666c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -19,8 +19,15 @@ import { type SubagentStartRequest, type SubagentStopReason, } from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { CodexAppServerWire } from './wire.ts' +import type { + SubprocessHandle, + SubprocessOutcome, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' +import { + CodexAppServerWire, + type CodexWireFailureFacts, +} from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 @@ -41,6 +48,53 @@ export const CODEX_PERMISSION_MODES = [ /** Safe default for unattended Codex runs. */ export const DEFAULT_CODEX_PERMISSION_MODE: CodexPermissionMode = 'never' +type CodexFailureStage = + | 'initialize' + | 'thread-start' + | CodexWireFailureFacts['stage'] + | 'process' + | 'teardown' + +interface CodexFailureFacts { + readonly stage: CodexFailureStage + readonly category: string + readonly httpStatus?: number | undefined + readonly outcome?: SubprocessOutcome | undefined +} + +function failureDiagnostic(facts: CodexFailureFacts): string { + const fields = [ + 'product: Codex', + `stage: ${facts.stage}`, + `category: ${facts.category}`, + ] + if (facts.httpStatus !== undefined) { + fields.push(`HTTP status: ${facts.httpStatus}`) + } + const exitCode = facts.outcome?.exitCode + if (exitCode !== null && exitCode !== undefined) { + fields.push(`exit code: ${exitCode}`) + } + const signal = facts.outcome?.signal + if (signal !== null && signal !== undefined) { + fields.push(`signal: ${signal}`) + } + return `Product subagent failure (${fields.join('; ')})` +} + +class CodexRunFailure extends Error { + constructor( + readonly facts: CodexFailureFacts, + cause?: unknown, + ) { + super( + `subagent-codex: ${failureDiagnostic(facts)}`, + cause === undefined ? undefined : { cause }, + ) + this.name = 'CodexRunFailure' + } +} + /** * Resolve the fixed app-server command for a platform. * @@ -111,19 +165,49 @@ export async function disposeCodexChild( wire: CodexAppServerWire, child: SubprocessHandle, ): Promise { - wire.close() - if (child.pid <= 0) { - await child.done.catch(() => {}) - return - } + const failures: Error[] = [] + let outcome: SubprocessOutcome | undefined try { - child.stdin?.end() - } catch { - // A concurrently closed stdin does not change tree ownership below. + wire.close() + } catch (error: unknown) { + failures.push(thrown(error)) } - child.terminate() - await child.waitForExit() - await child.done + + if (child.pid > 0) { + try { + child.stdin?.end() + } catch { + // A concurrently closed stdin does not change tree ownership below. + } + child.terminate() + try { + await child.waitForExit() + } catch (error: unknown) { + failures.push(thrown(error)) + } + try { + outcome = await child.done + } catch (error: unknown) { + failures.push(thrown(error)) + } + } else { + await child.done.catch(() => {}) + } + + const firstFailure = failures[0] + if (firstFailure === undefined) return + const facts = { + stage: 'teardown', + category: 'unknown', + outcome, + } as const + if (failures.length === 1) { + throw new CodexRunFailure(facts, firstFailure) + } + throw new AggregateError( + failures.map(failure => new CodexRunFailure(facts, failure)), + `subagent-codex: ${failureDiagnostic(facts)}`, + ) } /** @@ -141,13 +225,21 @@ export async function startCodexRun( throw new Error('subagent-codex: request was aborted before app-server startup') } - const child = spec.spawn({ - argv: codexAppServerArgv(), - cwd: spec.cwd, - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, - graceMs: spec.disposeGraceMs, - env: spec.env, - }) + let child: SubprocessHandle + try { + child = spec.spawn({ + argv: codexAppServerArgv(), + cwd: spec.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, + graceMs: spec.disposeGraceMs, + env: spec.env, + }) + } catch (error: unknown) { + throw new CodexRunFailure({ + stage: 'initialize', + category: 'unknown', + }, thrown(error)) + } const wire = new CodexAppServerWire( child.stdout as NonNullable, @@ -181,11 +273,15 @@ export async function startCodexRun( } const processFailure: Promise = child.done.then( - outcome => Promise.reject(new Error( - 'subagent-codex: app-server exited before the run settled ' - + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, - )), - (error: unknown) => Promise.reject(thrown(error)), + outcome => Promise.reject(new CodexRunFailure({ + stage: 'process', + category: 'process-exit', + outcome, + })), + (error: unknown) => Promise.reject(new CodexRunFailure({ + stage: 'process', + category: 'unknown', + }, thrown(error))), ) // A normal post-result dispose also closes the process. Keep that expected // late rejection observed after the result race has already settled. @@ -200,43 +296,81 @@ export async function startCodexRun( const onAbort = (): void => { requestCancel() } request.signal.addEventListener('abort', onAbort, { once: true }) + let startupStage: 'initialize' | 'thread-start' = 'initialize' try { wire.start() await Promise.race([wire.initialize(request.signal), processFailure]) + startupStage = 'thread-start' await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) + const cancelledBeforeCleanup = runAbort.signal.aborted + const failure = new CodexRunFailure({ + stage: startupStage, + category: 'unknown', + outcome: error instanceof CodexRunFailure + ? error.facts.outcome + : undefined, + }, thrown(error)) try { await disposeProcess() } catch (disposeError: unknown) { + const cleanupFailure = thrown(disposeError) throw new AggregateError( - [thrown(error), thrown(disposeError)], - 'subagent-codex: startup failed and app-server cleanup also failed', + [failure, cleanupFailure], + `${failure.message}; ${cleanupFailure.message}`, ) } - if (runAbort.signal.aborted) { + if (cancelledBeforeCleanup) { throw new Error('subagent-codex: request was aborted before run publication') } - throw thrown(error) + try { + request.signal.throwIfAborted() + } catch { + throw new Error('subagent-codex: request was aborted before run publication') + } + throw failure } const collectOutput = (): ContentBlock[] => wire.collectOutput() + let diagnostic: string | undefined + const recordFailureDiagnostic = (facts: CodexFailureFacts): string => { + const failure = failureDiagnostic(facts) + const permission = wire.collectDiagnostic() + diagnostic = permission === undefined + ? failure + : `${failure}\n${permission}` + return diagnostic + } const result: Promise = settleRunResult({ attempt: async () => { try { - return await Promise.race([ + const terminal = await Promise.race([ wire.runTurn(texts, runAbort.signal), processFailure, ]) + if (terminal.stopReason === 'completed') return terminal + const facts = wire.collectFailure() ?? { + stage: 'turn', + category: 'unknown', + } + return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } } catch (error: unknown) { // Give stderr data already queued in Node one turn to reach the wire // before settlement snapshots the diagnostic; later OS data is best-effort. await new Promise((resolve) => { setImmediate(resolve) }) - throw error + const wireFacts = wire.collectFailure() + const facts = error instanceof CodexRunFailure + ? error.facts + : wireFacts ?? { stage: 'turn', category: 'unknown' } + recordFailureDiagnostic(facts) + throw error instanceof CodexRunFailure + ? error + : new CodexRunFailure(facts, thrown(error)) } }, collectOutput, - collectDiagnostic: () => wire.collectDiagnostic(), + collectDiagnostic: () => diagnostic, cancelled: () => runAbort.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index cd94695863..9cd6b4f049 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -15,6 +15,13 @@ import type { CodexPermissionMode } from './run.ts' type JsonObject = Record +/** Product facts owned by the Codex wire after publication. */ +export interface CodexWireFailureFacts { + readonly stage: 'turn-start' | 'turn' + readonly category: string + readonly httpStatus?: number | undefined +} + const THREAD_PERMISSION_PARAMS: Readonly> = { never: { approvalPolicy: 'never' }, 'approve-for-me': { @@ -86,22 +93,78 @@ function unattendedDecision(params: JsonObject): 'cancel' | 'decline' { throw new Error('subagent-codex: app-server offered no unattended approval decision') } -function isContextWindowExceeded(turn: JsonObject): boolean { - if (turn.status !== 'failed') return false - const error = turn.error - return error !== null - && typeof error === 'object' - && !Array.isArray(error) - && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded' +function numericHttpStatus(value: unknown): number | undefined { + return typeof value === 'number' + && Number.isInteger(value) + && value >= 0 + && value <= 65_535 + ? value + : undefined } -function isSandboxFailure(turn: JsonObject): boolean { - if (turn.status !== 'failed') return false +function objectFailureInfo(value: JsonObject): { + readonly category: string + readonly httpStatus?: number | undefined +} { + const keys = Object.keys(value) + const category = keys[0] + if (keys.length !== 1 || category === undefined) { + return { category: 'unknown' } + } + const detail = value[category] + if (detail === null || typeof detail !== 'object' || Array.isArray(detail)) { + return { category: 'unknown' } + } + const fields = detail as JsonObject + switch (category) { + case 'httpConnectionFailed': + case 'responseStreamConnectionFailed': + case 'responseStreamDisconnected': + case 'responseTooManyFailedAttempts': + { + const httpStatus = numericHttpStatus(fields.httpStatusCode) + return httpStatus === undefined + ? { category } + : { category, httpStatus } + } + case 'activeTurnNotSteerable': + return { category } + default: + return { category: 'unknown' } + } +} + +function failureInfo(turn: JsonObject): { + readonly category: string + readonly httpStatus?: number | undefined +} { + if (turn.status !== 'failed') return { category: 'unknown' } const error = turn.error - return error !== null - && typeof error === 'object' - && !Array.isArray(error) - && (error as JsonObject).codexErrorInfo === 'sandboxError' + if (error === null || typeof error !== 'object' || Array.isArray(error)) { + return { category: 'unknown' } + } + const info = (error as JsonObject).codexErrorInfo + if (typeof info === 'string') { + switch (info) { + case 'contextWindowExceeded': + case 'sessionBudgetExceeded': + case 'usageLimitExceeded': + case 'serverOverloaded': + case 'cyberPolicy': + case 'internalServerError': + case 'unauthorized': + case 'badRequest': + case 'threadRollbackFailed': + case 'sandboxError': + case 'other': + return { category: info } + default: + return { category: 'unknown' } + } + } + return info !== null && typeof info === 'object' && !Array.isArray(info) + ? objectFailureInfo(info as JsonObject) + : { category: 'unknown' } } function unattendedDiagnostic( @@ -164,6 +227,7 @@ export class CodexAppServerWire { private lastFinalAnswer: string | undefined private lastUnphasedAnswer: string | undefined private diagnostic: string | undefined + private failure: CodexWireFailureFacts | undefined private diagnosticOrder = 0 private observationOrder = 0 private pendingDiagnostic: { @@ -262,22 +326,41 @@ export class CodexAppServerWire { }>() this.turnCompleted = completion const threadId = this.threadId as string - const response = object(await this.guarded(this.transport.request('turn/start', { - threadId, - input: texts.map(text => ({ type: 'text', text, text_elements: [] })), - }, signal), signal), 'turn/start response') - const turn = object(response.turn, 'turn/start turn') - this.commitTurnId(string(turn.id, 'turn/start turn id')) - - const completed = await this.guarded(completion.promise, signal) - const terminal = object(completed.params.turn, 'turn/completed turn') - const status = terminal.status - if (isContextWindowExceeded(terminal)) { - return { output: this.collectOutput(), stopReason: 'max-tokens' } + try { + const response = object(await this.guarded(this.transport.request('turn/start', { + threadId, + input: texts.map(text => ({ type: 'text', text, text_elements: [] })), + }, signal), signal), 'turn/start response') + const turn = object(response.turn, 'turn/start turn') + this.commitTurnId(string(turn.id, 'turn/start turn id')) + } catch (error: unknown) { + this.recordFailure({ stage: 'turn-start', category: 'unknown' }) + throw error } + + let completed: { + readonly params: JsonObject + readonly order: number + } + let terminal: JsonObject + try { + completed = await this.guarded(completion.promise, signal) + terminal = object(completed.params.turn, 'turn/completed turn') + } catch (error: unknown) { + this.recordFailure({ stage: 'turn', category: 'unknown' }) + throw error + } + const status = terminal.status if (status !== 'completed') { - const sandboxFailure = isSandboxFailure(terminal) - if (sandboxFailure) { + const parsed = failureInfo(terminal) + this.recordFailure(parsed.httpStatus === undefined + ? { stage: 'turn', category: parsed.category } + : { + stage: 'turn', + category: parsed.category, + httpStatus: parsed.httpStatus, + }) + if (parsed.category === 'sandboxError') { this.recordDiagnostic( 'sandbox execution', 'failed', @@ -285,15 +368,15 @@ export class CodexAppServerWire { completed.order, ) } - const detail = status === 'failed' - ? sandboxFailure - ? ': sandboxError' - : ': error' - : '' + if (parsed.category === 'contextWindowExceeded') { + return { output: this.collectOutput(), stopReason: 'max-tokens' } + } + const detail = status === 'failed' ? `: ${parsed.category}` : '' throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) } const output = this.collectOutput() if (output.length === 0) { + this.recordFailure({ stage: 'turn', category: 'unknown' }) throw new Error('subagent-codex: Codex completed without a final answer') } return { output, stopReason: 'completed' } @@ -330,6 +413,14 @@ export class CodexAppServerWire { return this.diagnostic } + /** + * The structured failure fact observed for this published turn. + * @returns a fixed stage/category pair and optional HTTP status. + */ + collectFailure(): CodexWireFailureFacts | undefined { + return this.failure + } + /** * Observe product stderr while retaining only enough tail to recognize fixed * permission signatures. The raw text is never copied into the diagnostic. @@ -475,6 +566,10 @@ export class CodexAppServerWire { ) } + private recordFailure(facts: CodexWireFailureFacts): void { + this.failure = facts + } + private nextObservationOrder(): number { this.observationOrder += 1 return this.observationOrder diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index a060d1555d..6b5bdb6793 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -160,6 +160,49 @@ describe('real @openai/codex 0.147.0 product', () => { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('codex-cli 0.147.0') + const schemaRoot = mkdtempSync(join(tmpdir(), 'dsh-codex-schema-')) + roots.push(schemaRoot) + await execFileAsync(process.execPath, [ + codexEntry, + 'app-server', + 'generate-json-schema', + '--out', + schemaRoot, + ], { env: { ...process.env, ...harness.env } }) + const schema = JSON.parse(readFileSync( + join(schemaRoot, 'ServerNotification.json'), + 'utf8', + )) as { + definitions: { + CodexErrorInfo: { + oneOf: Array<{ + enum?: string[] + properties?: Record + }> + } + } + } + expect(schema.definitions.CodexErrorInfo.oneOf[0]?.enum).toEqual([ + 'contextWindowExceeded', + 'sessionBudgetExceeded', + 'usageLimitExceeded', + 'serverOverloaded', + 'cyberPolicy', + 'internalServerError', + 'unauthorized', + 'badRequest', + 'threadRollbackFailed', + 'sandboxError', + 'other', + ]) + expect(schema.definitions.CodexErrorInfo.oneOf.slice(1).map(variant => + Object.keys(variant.properties ?? {})[0])).toEqual([ + 'httpConnectionFailed', + 'responseStreamConnectionFailed', + 'responseStreamDisconnected', + 'responseTooManyFailedAttempts', + 'activeTurnNotSteerable', + ]) const run = await harness.ctx.subagents.start('codex', { prompt: [{ type: 'text', text: task }], @@ -223,11 +266,15 @@ describe('real @openai/codex 0.147.0 product', () => { const result = await run.result expect(result.output).toEqual([]) expect(result.stopReason).toBe('error') + const diagnosticLines = result.diagnostic?.split('\n') ?? [] + expect(diagnosticLines[0]).toBe( + 'Product subagent failure (product: Codex; stage: turn; category: other)', + ) expect([ 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure', 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', - ]).toContain(result.diagnostic) + ]).toContain(diagnosticLines[1]) expect(result.diagnostic).not.toContain(command) expect(result.diagnostic).not.toContain(harness.workspace) await run.dispose() @@ -244,6 +291,49 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) + it('reports a real service failure and an early app-server exit safely', async () => { + { + const { harness } = await realHarness([{ + kind: 'error', + status: 503, + message: 'SECRET_TOKEN in /private/secret.txt', + }]) + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Exercise the service failure path.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + const result = await run.result + expect(result).toMatchObject({ output: [], stopReason: 'error' }) + expect(result.diagnostic).toBe( + 'Product subagent failure (product: Codex; stage: turn; category: internalServerError)', + ) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + expect(result.diagnostic).not.toContain('/private/secret.txt') + await run.dispose() + await expectQuiescent(harness.handles) + } + { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Exercise the process failure path.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await fixture.requestStarted + expect(harness.handles).toHaveLength(1) + harness.handles[0]!.terminate() + await harness.handles[0]!.done + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: unknown)', + stopReason: 'error', + }) + await run.dispose() + await expectQuiescent(harness.handles) + } + }, 60_000) + it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => { const sideEffect = 'bypass-side-effect' const command = process.platform === 'win32' diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 497303237a..e3864d0a19 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -128,6 +128,7 @@ interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean readonly doneError?: Error + readonly waitForExitError?: Error } interface FakeChild { @@ -171,6 +172,9 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { if (options.exitOnTerminate !== false) settle() }) const waitForExit = vi.fn(async (signal?: AbortSignal) => { + if (options.waitForExitError !== undefined) { + throw options.waitForExitError + } if (exited) return true if (signal === undefined) { await done.catch(() => {}) @@ -305,6 +309,37 @@ function turnCompleted( } } +function expectedFailureDiagnostic( + stage: 'initialize' | 'thread-start' | 'turn-start' | 'turn' | 'process' | 'teardown', + category: string, + options: { + readonly httpStatus?: number + readonly outcome?: Partial + } = {}, +): string { + const fields = [ + 'product: Codex', + `stage: ${stage}`, + `category: ${category}`, + ] + if (options.httpStatus !== undefined) { + fields.push(`HTTP status: ${options.httpStatus}`) + } + if ( + options.outcome?.exitCode !== null + && options.outcome?.exitCode !== undefined + ) { + fields.push(`exit code: ${options.outcome.exitCode}`) + } + if ( + options.outcome?.signal !== null + && options.outcome?.signal !== undefined + ) { + fields.push(`signal: ${options.outcome.signal}`) + } + return `Product subagent failure (${fields.join('; ')})` +} + describe('task admission and package contracts', () => { it('keeps the app-server command fixed on POSIX and Windows', () => { expect(codexAppServerArgv('linux')).toEqual([ @@ -558,23 +593,103 @@ describe('CodexAppServerWire', () => { wire.close() }) - it('maps only an explicit context-window failure to max-tokens', async () => { - const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal) - const turnStart = await child.peer.nextMethod('turn/start') - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.peer.send( - agentMessage('partial answer', null), - turnCompleted('failed', 'turn-1', 'thread-1', { - message: 'too much context', - codexErrorInfo: 'contextWindowExceeded', - }), - ) - await expect(result).resolves.toEqual({ - output: [{ type: 'text', text: 'partial answer' }], - stopReason: 'max-tokens', - }) - wire.close() + it('maps the complete string error union without changing stop reasons', async () => { + const categories = [ + 'contextWindowExceeded', + 'sessionBudgetExceeded', + 'usageLimitExceeded', + 'serverOverloaded', + 'cyberPolicy', + 'internalServerError', + 'unauthorized', + 'badRequest', + 'threadRollbackFailed', + 'sandboxError', + 'other', + ] as const + for (const category of categories) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: category, + }), + ) + if (category === 'contextWindowExceeded') { + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial answer' }], + stopReason: 'max-tokens', + }) + } else { + await expect(result).rejects.toThrow(`status failed: ${category}`) + } + expect(wire.collectFailure()).toEqual({ + stage: 'turn', + category, + }) + expect(JSON.stringify(wire.collectFailure())).not.toContain('SECRET_TOKEN') + expect(JSON.stringify(wire.collectFailure())).not.toContain('/private/secret.txt') + wire.close() + } + }) + + it('maps all object error variants and only numeric HTTP status', async () => { + const scenarios = [ + ['httpConnectionFailed', { httpStatusCode: 503 }, 503], + ['responseStreamConnectionFailed', { httpStatusCode: null }, undefined], + ['responseStreamDisconnected', {}, undefined], + ['responseTooManyFailedAttempts', { httpStatusCode: '503' }, undefined], + ['activeTurnNotSteerable', { turnKind: 'review' }, undefined], + ] as const + for (const [category, detail, httpStatus] of scenarios) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: { [category]: detail }, + })) + await expect(result).rejects.toThrow(`status failed: ${category}`) + expect(wire.collectFailure()).toEqual({ + stage: 'turn', + category, + ...(httpStatus === undefined ? {} : { httpStatus }), + }) + expect(JSON.stringify(wire.collectFailure())).not.toContain('turnKind') + wire.close() + } + }) + + it('uses unknown for version-external or malformed error info', async () => { + for (const codexErrorInfo of [ + 'futureError', + { futureVariant: { message: 'SECRET_TOKEN' } }, + { + httpConnectionFailed: { httpStatusCode: 503 }, + otherVariant: {}, + }, + { httpConnectionFailed: null }, + ]) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo, + })) + await expect(result).rejects.toThrow('status failed: unknown') + expect(wire.collectFailure()).toEqual({ + stage: 'turn', + category: 'unknown', + }) + wire.close() + } }) it('rejects invalid handshake, thread, and turn response shapes', async () => { @@ -604,6 +719,10 @@ describe('CodexAppServerWire', () => { const frame = await child.peer.nextMethod('turn/start') child.peer.respond(frame, { turn: { id: '' } }) await expect(pending).rejects.toThrow('turn/start turn id') + expect(wire.collectFailure()).toEqual({ + stage: 'turn-start', + category: 'unknown', + }) wire.close() } }) @@ -637,6 +756,10 @@ describe('CodexAppServerWire', () => { frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })], message: 'status failed', }, + { + frames: [turnCompleted('failed', 'turn-1', 'thread-1', 'SECRET_TOKEN')], + message: 'status failed', + }, { frames: [turnCompleted('interrupted')], message: 'status interrupted', @@ -651,8 +774,13 @@ describe('CodexAppServerWire', () => { const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() child.peer.send(...scenario.frames) await expect(result).rejects.toThrow(scenario.message) + expect(wire.collectFailure()).toEqual({ + stage: 'turn', + category: 'unknown', + }) wire.close() } }) @@ -1276,16 +1404,149 @@ describe('run lifecycle and quiescence', () => { await run.dispose() }) + it('reports turn-start failures and omits captured facts after success', async () => { + { + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: '' } }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn-start', 'unknown'), + stopReason: 'error', + }) + await run.dispose() + } + { + const { child, run, turnStart } = await publishRun() + child.peer.send({ + id: 'successful-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await child.peer.nextResponse('successful-approval') + child.peer.send( + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + await run.dispose() + } + }) + + it('preserves representative terminal categories, HTTP status, and mapping', async () => { + const scenarios = [ + ['contextWindowExceeded', 'max-tokens', undefined], + ['sessionBudgetExceeded', 'error', undefined], + [{ httpConnectionFailed: { httpStatusCode: 503 } }, 'error', 503], + [{ activeTurnNotSteerable: { turnKind: 'review' } }, 'error', undefined], + ['futureError', 'error', undefined], + ] as const + for (const [codexErrorInfo, stopReason, httpStatus] of scenarios) { + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo, + }), + ) + const category = typeof codexErrorInfo === 'string' + && codexErrorInfo !== 'futureError' + ? codexErrorInfo + : typeof codexErrorInfo === 'object' + ? Object.keys(codexErrorInfo)[0]! + : 'unknown' + const result = await run.result + expect(result).toEqual({ + output: [{ type: 'text', text: 'partial answer' }], + diagnostic: expectedFailureDiagnostic('turn', category, { + ...(httpStatus === undefined ? {} : { httpStatus }), + }), + stopReason, + }) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + expect(result.diagnostic).not.toContain('/private/secret.txt') + expect(result.diagnostic).not.toContain('turnKind') + await run.dispose() + } + }) + + it('uses safe unknown fallbacks when the wire supplies no failure fact', async () => { + { + const collectFailure = vi.spyOn( + CodexAppServerWire.prototype, + 'collectFailure', + ).mockReturnValue(undefined) + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'contextWindowExceeded', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn', 'unknown'), + stopReason: 'max-tokens', + }) + collectFailure.mockRestore() + await run.dispose() + } + { + const runTurn = vi.spyOn(CodexAppServerWire.prototype, 'runTurn') + .mockRejectedValueOnce(new Error('SECRET_TOKEN wire failure')) + const child = fakeChild() + const starting = startCodexRun(request(), runSpec(child)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { + thread: { id: 'thread-1', ephemeral: true }, + }) + const run = await starting + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn', 'unknown'), + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + runTurn.mockRestore() + await run.dispose() + } + }) + it('flattens child exit and protocol failures after publication', async () => { const errors: string[] = [] - { + const outcomes: SubprocessOutcome[] = [ + { exitCode: 9, signal: null }, + { exitCode: null, signal: 'SIGABRT' }, + { exitCode: 9, signal: 'SIGABRT' }, + { exitCode: null, signal: null }, + ] + for (const outcome of outcomes) { const child = fakeChild({ exitOnTerminate: false }) const { run } = await publishRun(child, undefined, { onError: (error) => { errors.push(error.message) }, }) - child.settle({ exitCode: 9, signal: null }) - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) - expect(errors.at(-1)).toContain('code 9') + child.settle(outcome) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('process', 'process-exit', { + outcome, + }), + stopReason: 'error', + }) + expect(errors.at(-1)).toBe( + `subagent-codex: ${expectedFailureDiagnostic('process', 'process-exit', { outcome })}`, + ) await run.dispose().catch(() => {}) } { @@ -1295,7 +1556,11 @@ describe('run lifecycle and quiescence', () => { }) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.fromChild.end() - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn', 'unknown'), + stopReason: 'error', + }) await run.dispose() } { @@ -1336,7 +1601,7 @@ describe('run lifecycle and quiescence', () => { })) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`, stopReason: 'error', }) await run.dispose() @@ -1356,7 +1621,7 @@ describe('run lifecycle and quiescence', () => { }) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, stopReason: 'error', }) await run.dispose() @@ -1378,7 +1643,7 @@ describe('run lifecycle and quiescence', () => { })) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, stopReason: 'error', }) expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN') @@ -1401,7 +1666,7 @@ describe('run lifecycle and quiescence', () => { })) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, stopReason: 'error', }) await run.dispose() @@ -1424,13 +1689,69 @@ describe('run lifecycle and quiescence', () => { )).rejects.toThrow('aborted before app-server startup') expect(spawn).not.toHaveBeenCalled() + const spawnFailure = startCodexRun(request(), { + cwd: process.cwd(), + permissionMode: DEFAULT_CODEX_PERMISSION_MODE, + env: {}, + disposeGraceMs: 10, + spawn: () => { throw new Error('SECRET_TOKEN spawn failure') }, + }) + await expect(spawnFailure) + .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) + await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN') + const child = fakeChild() const starting = startCodexRun(request(), runSpec(child)) const initialize = await child.peer.nextMethod('initialize') child.peer.respond(initialize, null) - await expect(starting).rejects.toThrow('invalid initialize response') + await expect(starting) + .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) + await expect(starting).rejects.not.toThrow('invalid initialize response') expect(child.terminate).toHaveBeenCalledTimes(1) + const cleanupRaceAbort = new AbortController() + const cleanupRaceChild = fakeChild({ exitOnTerminate: false }) + const cleanupRace = startCodexRun( + request(undefined, cleanupRaceAbort.signal), + runSpec(cleanupRaceChild), + ) + const cleanupRaceInitialize = await cleanupRaceChild.peer.nextMethod('initialize') + cleanupRaceChild.peer.respond(cleanupRaceInitialize, null) + await nextTask() + cleanupRaceAbort.abort(new Error('cancelled during cleanup')) + cleanupRaceChild.settle() + await expect(cleanupRace) + .rejects.toThrow('aborted before run publication') + + const threadChild = fakeChild() + const threadStarting = startCodexRun(request(), runSpec(threadChild)) + const threadInitialize = await threadChild.peer.nextMethod('initialize') + threadChild.peer.respond(threadInitialize, { userAgent: 'codex-cli 0.147.0' }) + await threadChild.peer.nextMethod('initialized') + const invalidThread = await threadChild.peer.nextMethod('thread/start') + threadChild.peer.respond(invalidThread, { thread: { id: '', ephemeral: true } }) + await expect(threadStarting) + .rejects.toThrow(expectedFailureDiagnostic('thread-start', 'unknown')) + await expect(threadStarting).rejects.not.toThrow('thread/start thread id') + + const exitedThreadChild = fakeChild({ exitOnTerminate: false }) + const exitedThreadStarting = startCodexRun( + request(), + runSpec(exitedThreadChild), + ) + const exitedThreadInitialize = await exitedThreadChild.peer.nextMethod('initialize') + exitedThreadChild.peer.respond(exitedThreadInitialize, { + userAgent: 'codex-cli 0.147.0', + }) + await exitedThreadChild.peer.nextMethod('initialized') + await exitedThreadChild.peer.nextMethod('thread/start') + exitedThreadChild.settle({ exitCode: 17, signal: 'SIGABRT' }) + await expect(exitedThreadStarting).rejects.toThrow(expectedFailureDiagnostic( + 'thread-start', + 'unknown', + { outcome: { exitCode: 17, signal: 'SIGABRT' } }, + )) + const stderrChild = fakeChild() const stderrStarting = startCodexRun(request(), runSpec(stderrChild)) const stderrInitialize = await stderrChild.peer.nextMethod('initialize') @@ -1486,8 +1807,12 @@ describe('run lifecycle and quiescence', () => { throw new Error('expected startup and rollback failures') } expect(error.errors).toEqual([ - expect.objectContaining({ message: 'spawn observer failed' }), - expect.objectContaining({ message: 'spawn observer failed' }), + expect.objectContaining({ + message: `subagent-codex: ${expectedFailureDiagnostic('initialize', 'unknown')}`, + }), + expect.objectContaining({ + message: `subagent-codex: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + }), ]) expect(child.terminate).toHaveBeenCalledTimes(1) }) @@ -1564,12 +1889,12 @@ describe('run lifecycle and quiescence', () => { })) await expect(first.run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`, stopReason: 'error', }) await expect(second.run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', + diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input`, stopReason: 'error', }) await Promise.all([first.run.dispose(), second.run.dispose()]) @@ -1628,7 +1953,7 @@ describe('run lifecycle and quiescence', () => { })) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval`, stopReason: 'error', }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ @@ -1638,7 +1963,7 @@ describe('run lifecycle and quiescence', () => { cwd: process.cwd(), })) expect(warnings).toEqual([ - expect.stringContaining('subagent-codex: child run failed (error): subagent-codex: Codex turn ended with status failed: error'), + expect.stringContaining(`subagent-codex: child run failed (error): subagent-codex: ${expectedFailureDiagnostic('turn', 'other')}`), ]) expect(warnings.join('\n')).not.toContain('SECRET_TOKEN') expect(warnings.join('\n')).not.toContain('/private/secret.txt') @@ -1702,7 +2027,7 @@ describe('disposeCodexChild', () => { }) const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) - .rejects.toThrow('close observer failed') + .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) } { const child = fakeChild() @@ -1711,4 +2036,23 @@ describe('disposeCodexChild', () => { await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() } }) + + it('aggregates wire-close and tree-wait failures with safe teardown facts', async () => { + const child = fakeChild({ + waitForExitError: new Error('SECRET_TOKEN wait failure'), + }) + const wire = defaultWire(child) + vi.spyOn(wire, 'close').mockImplementation(() => { + throw new Error('/private/secret.txt close failure') + }) + const disposal = disposeCodexChild(wire, child.handle) + await expect(disposal).rejects.toBeInstanceOf(AggregateError) + await expect(disposal).rejects.toThrow(expectedFailureDiagnostic( + 'teardown', + 'unknown', + { outcome: { exitCode: 0, signal: null } }, + )) + await expect(disposal).rejects.not.toThrow('SECRET_TOKEN') + await expect(disposal).rejects.not.toThrow('/private/secret.txt') + }) }) From efd2999aec2d9b963a074c9bcf1b08a561eee347 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 04:03:56 +0800 Subject: [PATCH 16/56] test(subagent): correlate Claude process failure facts --- .../tests/real-product.spec.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 344515d4cf..79d0a5a269 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -242,6 +242,14 @@ function expectedFailure( return `Product subagent failure (${fields.join('; ')})` } +function expectedObservedFailure(outcome: SubprocessOutcome): string { + return observedSdkMessages.some(message => + message.type === 'result' + && message.subtype === 'error_during_execution') + ? expectedFailure('query-run', 'error_during_execution', outcome) + : expectedFailure('process', 'process-exit', outcome) +} + function startRequest( harness: RealHarness, prompt: string, @@ -363,10 +371,7 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 const result = await run.result expect(result.output).toEqual([]) expect(result.stopReason).toBe('error') - expect([ - expectedFailure('process', 'process-exit', outcome), - expectedFailure('query-run', 'error_during_execution', outcome), - ]).toContain(result.diagnostic) + expect(result.diagnostic).toBe(expectedObservedFailure(outcome)) await run.dispose() expect(fixture.requests).toHaveLength(1) expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey) @@ -398,10 +403,7 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 expect(result.output).toEqual([]) expect(result.stopReason).toBe('error') const diagnosticLines = result.diagnostic?.split('\n') ?? [] - expect([ - expectedFailure('process', 'process-exit', outcome), - expectedFailure('query-run', 'error_during_execution', outcome), - ]).toContain(diagnosticLines[0]) + expect(diagnosticLines[0]).toBe(expectedObservedFailure(outcome)) expect(diagnosticLines[1]).toBe( 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', ) From f0b2d7c762f5975169dfcf93fe39d27a76c02505 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 05:01:37 +0800 Subject: [PATCH 17/56] refactor(subagent): avoid duplicate process diagnostics --- packages/subagent/subagent-codex/src/run.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 0a0627666c..6265587fab 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -71,13 +71,12 @@ function failureDiagnostic(facts: CodexFailureFacts): string { if (facts.httpStatus !== undefined) { fields.push(`HTTP status: ${facts.httpStatus}`) } - const exitCode = facts.outcome?.exitCode - if (exitCode !== null && exitCode !== undefined) { - fields.push(`exit code: ${exitCode}`) - } - const signal = facts.outcome?.signal - if (signal !== null && signal !== undefined) { - fields.push(`signal: ${signal}`) + const processFields = [ + ['exit code', facts.outcome?.exitCode], + ['signal', facts.outcome?.signal], + ] as const + for (const [label, value] of processFields) { + if (value !== null && value !== undefined) fields.push(`${label}: ${value}`) } return `Product subagent failure (${fields.join('; ')})` } From 4c6ff93535bf15e28569de908e1ab27e21dfc64b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 05:17:47 +0800 Subject: [PATCH 18/56] fix(subagent): preserve Claude failure provenance --- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent-claude-code/src/index.ts | 7 +- .../subagent/subagent-claude-code/src/run.ts | 67 ++++++++++++----- .../tests/subagent-claude-code.spec.ts | 72 +++++++++++++++++-- 6 files changed, 125 insertions(+), 29 deletions(-) diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index c2fe8df4db..c3a2b9b6be 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 21beb0a0534e601f9dd26d36f53d1fb5f09b4e07 -README.zh.md: c83999ee7b2d38e9c4ee57ba74df25217998bffd +README.md: 4a289236aca01b01fe8ff48c09776052729f6e52 +README.zh.md: 482e125136f9f9b7749112eb2130c52e383bb0b3 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 21beb0a053..4a289236ac 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -10,7 +10,7 @@ This package registers the fixed `claude-code` subagent provider. Each accepted The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every failure still maps to `error`: the four error subtypes in Agent SDK 0.3.220 retain their exact category, an error-marked or blank success becomes `invalid-success`, a missing result becomes `missing-result`, an unclassified query failure becomes `unknown`, and an early CLI exit becomes `process-exit`. The diagnostic also names the current `query-start`, `query-run`, `process`, or `teardown` stage and independently includes an observed exit code and signal. The provider produces neither `max-tokens` nor `refusal`. -Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains only on the internal cause chain. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains on the internal cause chain and in the Provider's Host log. Result failure and independent teardown failure remain separate. ## Native settings and interaction diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index c83999ee7b..482e125136 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -10,7 +10,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有失败仍映射为 `error`:Agent SDK 0.3.220 的四种错误子类型保留准确类别;标记为错误或内容空白的成功消息成为 `invalid-success`;缺失结果成为 `missing-result`;未分类的 query 失败成为 `unknown`;CLI 提前退出成为 `process-exit`。诊断还会注明当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,并分别保留已观测到的退出码与信号。该提供方不会产生 `max-tokens` 或 `refusal`。 -本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链上。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链与提供方的 Host 日志中。结果失败与独立的清理失败仍彼此分离。 ## 原生设置与交互 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 1359b1d642..eb7779c4c3 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -98,7 +98,12 @@ class ClaudeCodeProvider implements SubagentProvider { 'subagent-claude-code: request was aborted before SDK startup', ) } - throw claudeCodeStartupFailure(error) + const failure = claudeCodeStartupFailure(error) + this.ctx.logger.warn( + 'subagent-claude-code: child start failed: %o', + failure, + ) + throw failure } const spec: ClaudeCodeRunSpec = { cwd, diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 5ea41d9bac..d10fd647f8 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -160,7 +160,7 @@ export interface ClaudeCodeRunSpec { readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle - /** Diagnostic sink for a post-publication error flattened into a result. */ + /** Host diagnostic sink for a product failure kept outside model-visible text. */ readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } @@ -224,11 +224,13 @@ export function successfulResult(message: SDKResultMessage): string { * iterator completion. * @param query - published official SDK query. * @param onPermissionDenied - records a safe fact when the SDK reports native denial. + * @param onResult - records that the SDK supplied a terminal result message. * @returns the completed shared result. */ export async function consumeClaudeQuery( query: AsyncIterable, onPermissionDenied?: () => void, + onResult?: () => void, ): Promise { let answer: string | undefined for await (const message of query) { @@ -237,6 +239,7 @@ export async function consumeClaudeQuery( continue } if (message.type !== 'result') continue + onResult?.() answer = successfulResult(message) } if (answer === undefined) { @@ -394,6 +397,13 @@ export async function startClaudeCodeRun( } const onAbort = (): void => { requestCancel() } request.signal.addEventListener('abort', onAbort, { once: true }) + const reportFailure = (error: Error): void => { + try { + spec.onError?.(error, 'error') + } catch { + // Host diagnostic logging cannot replace the product failure. + } + } let child: SubprocessHandle | undefined let query: Query | undefined @@ -435,6 +445,7 @@ export async function startClaudeCodeRun( } } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) + // Let child.done publish a concurrently observed exit before classification. await Promise.resolve() const startupOutcome = managedProcess?.outcome const startupFacts = { @@ -453,10 +464,12 @@ export async function startClaudeCodeRun( } catch (disposeError: unknown) { const failure = startupFailure() const cleanupFailure = thrown(disposeError) - throw new AggregateError( + const aggregate = new AggregateError( [failure, cleanupFailure], `${failure.message}; ${cleanupFailure.message}`, ) + reportFailure(aggregate) + throw aggregate } } else if (query !== undefined) { try { @@ -467,10 +480,12 @@ export async function startClaudeCodeRun( stage: 'teardown', category: 'unknown', }, thrown(disposeError)) - throw new AggregateError( + const aggregate = new AggregateError( [failure, cleanupFailure], `${failure.message}; ${cleanupFailure.message}`, ) + reportFailure(aggregate) + throw aggregate } } try { @@ -478,11 +493,14 @@ export async function startClaudeCodeRun( } catch { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw startupFailure() + const failure = startupFailure() + reportFailure(failure) + throw failure } const publishedQuery = query const publishedChild = child + let receivedResult = false const result = settleRunResult({ attempt: async () => { try { @@ -493,19 +511,29 @@ export async function startClaudeCodeRun( 'denied', 'Claude Code denied the request before an interactive prompt', )) + }, () => { + receivedResult = true }) } catch (error: unknown) { const processOutcome = managedProcess?.outcome - const facts = error instanceof ClaudeCodeFailure - ? { ...error.facts, outcome: processOutcome } - : processOutcome === undefined - ? { stage: 'query-run', category: 'unknown' } as const - : { - stage: 'process', - category: 'process-exit', - outcome: processOutcome, - } as const + let facts: ClaudeCodeFailureFacts + if (error instanceof ClaudeCodeFailure) { + facts = { ...error.facts, outcome: processOutcome } + } else if (processOutcome !== undefined && !receivedResult) { + facts = { + stage: 'process', + category: 'process-exit', + outcome: processOutcome, + } + } else { + facts = { + stage: 'query-run', + category: 'unknown', + outcome: processOutcome, + } + } prependFailureDiagnostic(facts) + // Keep the SDK category and cause; the diagnostic adds later process facts. throw error instanceof ClaudeCodeFailure ? error : new ClaudeCodeFailure(facts, thrown(error)) @@ -525,9 +553,14 @@ export async function startClaudeCodeRun( signal: request.signal, onAbort, requestCancel, - teardown: () => disposeClaudeCodeChild( - publishedQuery, - publishedChild, - ), + teardown: async () => { + try { + await disposeClaudeCodeChild(publishedQuery, publishedChild) + } catch (error: unknown) { + const failure = thrown(error) + reportFailure(failure) + throw failure + } + }, }) } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 786f19d37c..b853dc9004 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -415,8 +415,17 @@ describe('task admission and package contracts', () => { expect(queryMock).not.toHaveBeenCalled() resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) - await expect(ctx.subagents.start('claude-code', request())) + const missingExecutable = ctx.subagents.start('claude-code', request()) + await expect(missingExecutable) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(missingExecutable).rejects.not.toThrow('claude missing from PATH') + expect(warn).toHaveBeenCalledWith( + 'subagent-claude-code: child start failed: %o', + expect.any(Error), + ) + expect(warn.mock.calls[0]?.[1]).toMatchObject({ + cause: expect.objectContaining({ message: 'claude missing from PATH' }), + }) expect(queryMock).not.toHaveBeenCalled() const resolutionAbort = new AbortController() @@ -914,14 +923,29 @@ describe('run publication, cancellation, and settlement', () => { }) it('fails closed when iteration rejects after a result', async () => { - const fixture = fakeRun( - [success('partial final')], - new Error('iterator boom'), - ) - const run = await startClaudeCodeRun(request(), fixture.spec) + const child = fakeChild() + const outcome = { exitCode: 31, signal: null } as const + async function* stream(): AsyncGenerator { + yield success('partial final') + child.settle(outcome) + await Promise.resolve() + throw new Error('iterator boom') + } + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return Object.assign(stream(), { close: vi.fn() }) as unknown as Query + }) + const run = await startClaudeCodeRun(request(), { + cwd: '/workspace', + executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: expectedFailureDiagnostic('query-run', 'unknown'), + diagnostic: expectedFailureDiagnostic('query-run', 'unknown', outcome), stopReason: 'error', }) await run.dispose() @@ -1141,14 +1165,25 @@ describe('run publication, cancellation, and settlement', () => { queryMock.mockImplementationOnce(() => { throw new Error('query failed before resource creation') }) + const queryFailureOnError = vi.fn() const queryFailure = startClaudeCodeRun(request(), { ...unused.spec, + onError: queryFailureOnError, }) await expect(queryFailure) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) await expect(queryFailure).rejects.not.toThrow( 'query failed before resource creation', ) + expect(queryFailureOnError).toHaveBeenCalledWith( + expect.any(Error), + 'error', + ) + expect(queryFailureOnError.mock.calls[0]?.[0]).toMatchObject({ + cause: expect.objectContaining({ + message: 'query failed before resource creation', + }), + }) const spawned = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] @@ -1225,6 +1260,29 @@ describe('query and process disposal', () => { }) }) + it('reports a published teardown failure to the Host diagnostic sink', async () => { + const fixture = fakeRun([success('exact answer')]) + const onError = vi.fn() + const run = await startClaudeCodeRun(request(), { + ...fixture.spec, + onError, + }) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + fixture.close.mockImplementationOnce(() => { + throw new Error('SECRET_TOKEN close failure') + }) + await expect(run.dispose()).rejects.toThrow( + expectedFailureDiagnostic('teardown', 'unknown', { + exitCode: 0, + signal: null, + }), + ) + expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error') + expect(onError.mock.calls[0]?.[0]).toMatchObject({ + cause: expect.objectContaining({ message: 'SECRET_TOKEN close failure' }), + }) + }) + it('does not finish disposal before the managed tree exits', async () => { const child = fakeChild({ exitOnTerminate: false }) let disposed = false From 3621716704fe5e88317a5c498f25e229194b2626 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 05:37:14 +0800 Subject: [PATCH 19/56] test(subagent): type Claude failure cause assertions --- .../tests/subagent-claude-code.spec.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index b853dc9004..c2b4a033e9 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -76,6 +76,12 @@ async function nextTask(): Promise { await new Promise((resolve) => { setImmediate(resolve) }) } +function errorCause(value: unknown): Error | undefined { + return value instanceof Error && value.cause instanceof Error + ? value.cause + : undefined +} + interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean @@ -423,9 +429,8 @@ describe('task admission and package contracts', () => { 'subagent-claude-code: child start failed: %o', expect.any(Error), ) - expect(warn.mock.calls[0]?.[1]).toMatchObject({ - cause: expect.objectContaining({ message: 'claude missing from PATH' }), - }) + expect(errorCause(warn.mock.calls[0]?.[1] as unknown)?.message) + .toBe('claude missing from PATH') expect(queryMock).not.toHaveBeenCalled() const resolutionAbort = new AbortController() @@ -1165,7 +1170,9 @@ describe('run publication, cancellation, and settlement', () => { queryMock.mockImplementationOnce(() => { throw new Error('query failed before resource creation') }) - const queryFailureOnError = vi.fn() + const queryFailureOnError = vi.fn< + NonNullable + >() const queryFailure = startClaudeCodeRun(request(), { ...unused.spec, onError: queryFailureOnError, @@ -1179,11 +1186,8 @@ describe('run publication, cancellation, and settlement', () => { expect.any(Error), 'error', ) - expect(queryFailureOnError.mock.calls[0]?.[0]).toMatchObject({ - cause: expect.objectContaining({ - message: 'query failed before resource creation', - }), - }) + expect(errorCause(queryFailureOnError.mock.calls[0]?.[0])?.message) + .toBe('query failed before resource creation') const spawned = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] @@ -1262,7 +1266,7 @@ describe('query and process disposal', () => { it('reports a published teardown failure to the Host diagnostic sink', async () => { const fixture = fakeRun([success('exact answer')]) - const onError = vi.fn() + const onError = vi.fn>() const run = await startClaudeCodeRun(request(), { ...fixture.spec, onError, @@ -1278,9 +1282,8 @@ describe('query and process disposal', () => { }), ) expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error') - expect(onError.mock.calls[0]?.[0]).toMatchObject({ - cause: expect.objectContaining({ message: 'SECRET_TOKEN close failure' }), - }) + expect(errorCause(onError.mock.calls[0]?.[0])?.message) + .toBe('SECRET_TOKEN close failure') }) it('does not finish disposal before the managed tree exits', async () => { From 85e821b9260555db05100ce13bb34e8f7869ddab Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 11:28:58 +0800 Subject: [PATCH 20/56] fix(ci): remove flaky subprocess readiness handshake --- .../subprocess-local/tests/fixtures/process-exit-host.ts | 2 -- .../subprocess/subprocess-local/tests/process-exit.spec.ts | 4 ---- 2 files changed, 6 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index e59289be09..721c5134c3 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -13,7 +13,6 @@ if ((kind !== 'ordinary' && kind !== 'terminal') } const treeState = join(root, 'tree.json') -const ready = join(root, 'ready') const proceed = join(root, 'proceed') const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) @@ -58,7 +57,6 @@ const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unkn if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { throw new Error('managed tree published invalid process ids') } -await writeFile(ready, 'ready') await waitForFile(proceed) if (trigger === 'dispose') { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index 217338fa1e..cdfc4f4ae0 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -107,10 +107,6 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { let treeGone = false try { state = await readTree(join(root, 'tree.json')) - await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { - interval: 10, - timeout: scenarioTimeoutMs, - }) if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) await writeFile(join(root, 'proceed'), 'proceed') const outcome = await child From 761d9d1978dfa375d9a4b88d8b7117d197bdbc9d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 18 Aug 2026 12:06:40 +0800 Subject: [PATCH 21/56] fix(web): render command errors as banners --- ...ommand-image-attachment-envelope.i18n.yaml | 4 ++-- ...08-17-command-image-attachment-envelope.md | 5 +++- ...17-command-image-attachment-envelope.zh.md | 5 +++- .../tests/command-image-envelope.snapshot.ts | 12 ++++++---- packages/client/ui-commands/README.i18n.yaml | 4 ++-- packages/client/ui-commands/README.md | 2 +- packages/client/ui-commands/README.zh.md | 2 +- .../src/client/input/facade.ts | 2 +- .../ui-conversation/src/client/input/hub.ts | 2 +- .../src/client/skeleton/InputBar.module.css | 7 +----- .../src/client/skeleton/InputBar.tsx | 13 +++++++---- .../tests/input-bar.client.spec.tsx | 23 +++++++++++++++---- 12 files changed, 51 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml index f8c6bd547e..fbce9616aa 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md -2026-08-17-command-image-attachment-envelope.md: d811a37284b944949482a94842287122219d2314 -2026-08-17-command-image-attachment-envelope.zh.md: 38d5cecfbb59949e8c9f86f937ed7a7669dcdb6c +2026-08-17-command-image-attachment-envelope.md: f651658ef0b655d6d190d25f54c2afaa088ad7be +2026-08-17-command-image-attachment-envelope.zh.md: dcf2f72feb6dc4c1a158acd6e66924f3c46f1b3e diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md index d811a37284..f651658ef0 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md @@ -16,11 +16,13 @@ The submission envelope is modeled end to end, and every command route either co **Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot. +**Generic identity, image-specific payload.** Browser drafts and durable references already use `DraftAttachmentId` and `AttachmentId`; the command RPC carries encoded bytes rather than an image identifier. The wire remains `EncodedImageAttachment[]`, and the declaration remains `input.images`, while images are the only non-text attachment with defined admission and model-block semantics. + **Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`. **Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan` folds the images into the message it already steers. Both producers reject sub-commands whose grammar has no carrier (`/goal pause`, bare `/plan`, `/plan off`) with a direct error, which keeps the composer's images in place. -**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine renders the rejection as one composer notice with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them. +**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them. ## Testing @@ -33,6 +35,7 @@ Registry executor enforcement, admission failure settlement, and frozen invocati - **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility. - **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors. - **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself. +- **Generalize the command wire to a multimedia identifier** — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining `AttachmentId`. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md index 38d5cecfbb..dcf2f72feb 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md @@ -16,11 +16,13 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片 **声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。 +**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 `DraftAttachmentId` 和 `AttachmentId`;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 `EncodedImageAttachment[]`,声明保持 `input.images`。 + **执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。 **模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan` 把图片并入它本就要 steer 的消息。两个生产方都会拒绝语法上没有载体的子命令(`/goal pause`、不带参数的 `/plan`、`/plan off`),直接返回错误,composer 的图片原地保留。 -**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机把拒绝渲染为一条 composer 通知,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。 +**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。 ## Testing @@ -33,6 +35,7 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片 - **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。 - **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。 - **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。 +- **把命令 wire 泛化成多媒体标识**——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,`AttachmentId` 保持不变。 ## Consequences diff --git a/apps/web/tests/command-image-envelope.snapshot.ts b/apps/web/tests/command-image-envelope.snapshot.ts index a4601a2541..f0a958f5cc 100644 --- a/apps/web/tests/command-image-envelope.snapshot.ts +++ b/apps/web/tests/command-image-envelope.snapshot.ts @@ -3,7 +3,7 @@ // bundles via AppWebEntry, keyless FixtureApiClient transport): an enter // submission carrying composer images resolves only through a command whose // descriptor declares `input.images`. A non-declaring command refuses with -// one composer notice and everything retained; a declaring command consumes +// one composer error banner and everything retained; a declaring command consumes // the images — serialized through the real draft-image chain into the // commands/execute payload — and clears the composer on success. import { fireEvent, screen, waitFor } from '@testing-library/react' @@ -46,15 +46,17 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft fireEvent.change(textarea, { target: { value: '/echo hello' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - // Several live-region elements exist (session activity among them); the - // refusal is the status whose text is the localized notice. + // The refusal rides the same transient error banner as other composer + // failures; session activity remains on its separate status live region. const notice = await waitFor(() => { - const el = [...document.querySelectorAll('[role="status"]')] + const el = [...document.querySelectorAll('[role="alert"]')] .find(candidate => candidate.textContent?.includes('image attachments') ?? false) - if (el === undefined) throw new Error('composer refusal notice missing') + if (el === undefined) throw new Error('composer refusal banner missing') return el }, { timeout: 5_000 }) expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first') + expect([...document.querySelectorAll('[role="status"]')] + .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false) // The whole envelope is retained: draft text and the rail thumbnail. expect(textarea.value).toBe('/echo hello') const rail = document.querySelector('[role="group"][aria-label="Pending images"]') diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 1c99d20187..4fe118592e 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md -README.md: 7d4a700f70eb93ce1feea6b88eeeae6643039445 -README.zh.md: 0896ee0393ab93927b6b7ce2712028e3e0825ba3 +README.md: 2140495a44110d5e4b33e4cc8f539959752ac185 +README.zh.md: afa47cd18505b9afbd3e867d131e9796db598895 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 7d4a700f70..2140495a44 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -8,7 +8,7 @@ Client command API (`ctx.commandUi`): the session-keyed command-directory cache, `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. -`matchEnter` also enforces the submission envelope: when the composer submits with image attachments, only a host command declaring `input.images` proceeds (its claim carries `images: true` and its submit forwards the serialized payloads to `command.execute`); every other command route — contribution popup, decorated popup, non-declaring claim, bare detached execute — throws the localized `notice.imagesUnsupported` refusal, which the input machine renders as one composer notice with the draft and images retained. An image-carrying submit whose host handler answers an error result maps to an error outcome so the composer keeps the images; imageless submits keep the plain success mapping because the durable flow node owns the outcome rendering. +`matchEnter` also enforces the submission envelope: when the composer submits with image attachments, only a host command declaring `input.images` proceeds (its claim carries `images: true` and its submit forwards the serialized payloads to `command.execute`); every other command route — contribution popup, decorated popup, non-declaring claim, bare detached execute — throws the localized `notice.imagesUnsupported` refusal, which the input machine publishes as one error notice and the composer renders as a transient Toast banner with the draft and images retained. An image-carrying submit whose host handler answers an error result maps to an error outcome so the composer keeps the images; imageless submits keep the plain success mapping because the durable flow node owns the outcome rendering. After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. Listener failures are logged and contained one by one; they cannot change the already-admitted command result or prevent later listeners from running. diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 0896ee0393..afa47cd185 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -8,7 +8,7 @@ `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 -`matchEnter` 还强制执行提交信封:composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续(其 claim 携带 `images: true`,其 submit 把序列化载荷转交 `command.execute`);其余每条命令路径——contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行——都会抛出本地化的 `notice.imagesUnsupported` 拒绝,输入状态机将其渲染为一条 composer 通知,草稿与图片原样保留。带图提交若宿主处理器返回错误结果,则映射为错误 outcome,composer 保留图片;不带图的提交维持原有的一律成功映射,因为结果呈现由持久化 flow 节点负责。 +`matchEnter` 还强制执行提交信封:composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续(其 claim 携带 `images: true`,其 submit 把序列化载荷转交 `command.execute`);其余每条命令路径——contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行——都会抛出本地化的 `notice.imagesUnsupported` 拒绝,输入状态机发布一条错误通知,composer 以瞬态 Toast 横幅呈现它,草稿与图片原样保留。带图提交若宿主处理器返回错误结果,则映射为错误 outcome,composer 保留图片;不带图的提交维持原有的一律成功映射,因为结果呈现由持久化 flow 节点负责。 `command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。监听器失败会逐项记录并隔离,不会改变已经准入的命令结果,也不会阻止后续监听器运行。 diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index b708a56c12..2783aea854 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -78,7 +78,7 @@ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() export class SessionInputShell implements SessionInput { /** Published machine state + queue overlay (the InputZone currency source). */ readonly state: SnapshotStore - /** Latest surfaced notice (null after clear); the wiring renders it beside the error strip. */ + /** Latest surfaced notice (null after clear); the bar renders errors as banners and information inline. */ readonly notices: SnapshotStore = createSnapshotStore(null) /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index ee02233b2f..789f18766c 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -159,7 +159,7 @@ export class InputHub implements SessionInputResolver { * Default sink: optimistic clear + prompt. The session is always a real * host entity (materialized when its workspace was picked), so there is * exactly one path; a failed first prompt is an ordinary prompt failure - * (error strip via promptError, draft restored only while untouched). + * (banner via promptError, draft restored only while untouched). */ private sink( session: SessionFace, diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 6635322a5a..e75f5d5f5e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -24,7 +24,7 @@ /* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8; the sides narrow with the shared width axis); the bottom gradient mask is owned by the chat scroller. No top pad: the composer stack's gap owns - the space above; error/status strips still carry their own margin. */ + the space above; the status strip still carries its own margin. */ padding: 0 var(--dsh-composer-side-clearance) 8px; } @@ -47,11 +47,6 @@ line-height: 18px; } -.noticeError { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); -} - .card { box-sizing: border-box; position: relative; /* overlay anchor positioning context */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 501001215d..bdb5c592cd 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -65,9 +65,9 @@ export function InputBar({ [draftImages, input?.imageIds], ) const empty = draft.trim() === '' && attachments.length === 0 - // Transient error banner (image-intake rejections and prompt failures): the - // seq keys the Toast so an identical repeated message restarts the - // hold-then-fade cycle instead of silently reusing the faded one. + // Transient error banner (machine notices, image-intake rejections, and + // prompt failures): the seq keys the Toast so an identical repeated message + // restarts the hold-then-fade cycle instead of reusing the faded one. const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) const toastSeq = useRef(0) const showToast = useCallback((text: string) => { @@ -91,6 +91,9 @@ export function InputBar({ ? attachmentErrorText(t, promptError.error.details.reason, imageLimits) : `${promptError.error.message} (${promptError.error.code})`) }, [promptError, showToast, t, imageLimits]) + useEffect(() => { + if (notice?.level === 'error') showToast(notice.text) + }, [notice, showToast]) const inputRef = useRef(null) const cardRef = useRef(null) const scrollRef = useRef(null) @@ -580,8 +583,8 @@ export function InputBar({ onDone={dismissToast} /> )} - {notice !== null && ( -

+ {notice?.level === 'info' && ( +
{notice.text}
)} diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 38b8bd708d..c1b57872bd 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -2,7 +2,7 @@ // InputBar behavior over the machine wiring: Enter-send semantics (IME guard, // Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running // semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock, -// decoration backdrop, error/notice strips, and the focus-keeping mousedown. +// decoration backdrop, error banners, status strips, and the focus-keeping mousedown. import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' @@ -1202,10 +1202,25 @@ describe('strips and variants', () => { } }) - it('renders the notice strip from the machine notice store', () => { + it('announces an error notice from the machine store as a fading toast', () => { + vi.useFakeTimers() + try { + const { view, shell } = bench() + act(() => { shell.notify('error', '命令失败了') }) + expect(view.getByRole('alert').textContent).toContain('命令失败了') + expect(view.queryByRole('status')).toBeNull() + act(() => { vi.advanceTimersByTime(4000) }) + expect(view.queryByRole('alert')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('renders an information notice from the machine store as a status strip', () => { const { view, shell } = bench() - act(() => { shell.notify('error', '命令失败了') }) - expect(view.getByText('命令失败了')).toBeTruthy() + act(() => { shell.notify('info', '命令完成了') }) + expect(view.getByRole('status').textContent).toBe('命令完成了') + expect(view.queryByRole('alert')).toBeNull() }) it('hero variant adds the hero class and accessory row renders', () => { From 94f6fd1306e4eb7da1a22d7e2b68b61871505504 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 13:50:53 +0800 Subject: [PATCH 22/56] Support MCP in packaged Python runtime --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 + ...-executable-sdk-runtime-distribution.zh.md | 2 + .../workflows/build-exe-for-python-sdk.yml | 3 + .github/workflows/python-release.yml | 1 + .gitlab-ci.yml | 3 +- pnpm-lock.yaml | 3 + python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 + python/sdk-runtime/README.zh.md | 2 + python/sdk-runtime/package.json | 1 + python/sdk/tests/test_smoke_model.py | 41 ++++ scripts/smoke-python-runtime.py | 191 +++++++++++++++++- 13 files changed, 253 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 37e39c7dbf..7594f17605 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 3715aa5b1148eb97faa04d5ac5778cceb47b97fc -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: a9632deb847d0aa24f376edb017bd70c4ff81302 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: a09c1438d61c3ad6b92d694360a1591764f3d67e +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ce6568d7303433a5a4ca57ac76f67051944ed997 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 3715aa5b11..a09c1438d6 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -38,6 +38,8 @@ Inside the exe's VFS sits a **real package tree in build-artifact form** (each p The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. + ### Build pipeline and artifacts [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index a9632deb84..ce6568d730 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -38,6 +38,8 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都显式列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 + ### 构建流水线与产物 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 8c6569fa07..5fc6de459d 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -272,6 +272,8 @@ jobs: deepseek-harness-sdk=="$VERSION" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ + --scenario sdk-mcp - name: Check Linux GLIBC requirements if: runner.os == 'Linux' @@ -309,6 +311,7 @@ jobs: /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default + /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp ' - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 4a2f05b10b..a395a8e8bd 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -72,6 +72,7 @@ jobs: run: | python -m pip install --find-links dist "deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}" python scripts/smoke-python-runtime.py --scenario sdk-default + python scripts/smoke-python-runtime.py --scenario sdk-mcp validate: name: Validate release candidate diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 87c4e8dccb..cf5a093f69 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -45,6 +45,7 @@ sdk-wheel: - python -m venv .wheel-smoke - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_WHEEL_VERSION" - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default + - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-mcp - | if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then readelf --version-info "$EXE" > glibc-versions.txt @@ -56,7 +57,7 @@ sdk-wheel: linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp" fi - | if [ "$PLATFORM" = macos-arm64 ]; then diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f36dbcf78..da4fb969dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8460,6 +8460,9 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-output-retention': specifier: workspace:^ version: link:../../packages/util/output-retention diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 1d4b9fd739..d04296dd33 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 592ce091f3b6c4bc151dfdee9d313b7c970c95de -README.zh.md: 0a29281a39c885bc3c40e017bdc9475e0065605f +README.md: c00357dbad74f8de705789bce8e4a55cc5fd67b1 +README.zh.md: a62edb4cd98338d22332f8e124c3ecb3f2356ace diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 592ce091f3..c00357dbad 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -13,6 +13,8 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte Both carriers hold the same content, defined once: the [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. +The bundled plugin set includes `@deepseek-ai/dsh-mcp-client`, so an external Cordis config can connect to stdio or Streamable HTTP MCP servers and expose their tools to the model. The wheel does not bundle MCP server programs or credentials: a stdio config supplies its executable and arguments, while a Streamable HTTP config supplies its URL and headers. The bridge supports MCP tools; MCP Resources and Prompts remain unsupported. + A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0a29281a39..a62edb4cd9 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -13,6 +13,8 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 +内置插件集合包含 `@deepseek-ai/dsh-mcp-client`,因此外部 Cordis 配置可以连接 stdio 或 Streamable HTTP MCP server,并向模型提供这些 server 的工具。wheel 包不包含 MCP server 程序或凭据:stdio 配置需要提供可执行程序及其参数,Streamable HTTP 配置需要提供 URL 和请求头。该桥接仅支持 MCP 工具,尚不支持 MCP Resources 与 Prompts。 + exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 083a8a87d7..cd43257014 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", diff --git a/python/sdk/tests/test_smoke_model.py b/python/sdk/tests/test_smoke_model.py index d4be72032d..c0e6b9f70e 100644 --- a/python/sdk/tests/test_smoke_model.py +++ b/python/sdk/tests/test_smoke_model.py @@ -30,3 +30,44 @@ def test_child_prompt_precedes_runtime_context(prompt_name: str, expected: str) for chunk in chunks for choice in chunk.get("choices", []) ) + + +def test_mcp_smoke_requests_the_discovered_tool() -> None: + chunks = SMOKE["completion_chunks"]({ + "messages": [{"role": "user", "content": SMOKE["MCP_PROMPT"]}], + "tools": [{"type": "function", "function": {"name": "mcp__fixture__add"}}], + }) + + calls = [ + call + for chunk in chunks + for choice in chunk.get("choices", []) + for call in choice.get("delta", {}).get("tool_calls", []) + ] + assert calls[0]["function"] == { + "name": "mcp__fixture__add", + "arguments": '{"a": 19, "b": 23}', + } + + +def test_mcp_smoke_accepts_the_external_server_result() -> None: + chunks = SMOKE["completion_chunks"]({ + "messages": [ + {"role": "user", "content": SMOKE["MCP_PROMPT"]}, + { + "role": "assistant", + "tool_calls": [{ + "id": "mcp-add", + "type": "function", + "function": {"name": "mcp__fixture__add", "arguments": '{}'}, + }], + }, + {"role": "tool", "tool_call_id": "mcp-add", "content": "42"}, + ], + }) + + assert any( + choice.get("delta", {}).get("content") == SMOKE["MCP_TEXT"] + for chunk in chunks + for choice in chunk.get("choices", []) + ) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index b517006475..76f3e4f60a 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -9,6 +9,7 @@ import json import os import queue import subprocess +import sys import tempfile import threading import time @@ -32,6 +33,8 @@ MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools." FS_SEARCH_TEXT = "filesystem search smoke ok" FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK" +MCP_PROMPT = "Exercise the packaged MCP client with one external stdio server." +MCP_TEXT = "MCP client smoke ok" MINIMAL_CORDIS = ( Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" ) @@ -143,6 +146,130 @@ FS_SEARCH_CORDIS = """\ config: sampleOverCapGlobResults: false """ +MCP_SERVER_SCRIPT = """\ +import json +import os +import sys + + +log_path = os.environ.get("MCP_SMOKE_LOG") + + +def send(message): + sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\\n") + sys.stdout.flush() + + +for line in sys.stdin: + request = json.loads(line) + if log_path is not None: + with open(log_path, "a", encoding="utf-8") as log: + log.write(str(request.get("method")) + "\\n") + request_id = request.get("id") + if request_id is None: + continue + method = request.get("method") + if method == "initialize": + send({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": request["params"]["protocolVersion"], + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "python-wheel-fixture", "version": "1.0.0"}, + }, + }) + elif method == "tools/list": + send({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [{ + "name": "add", + "description": "Add two numbers.", + "inputSchema": { + "type": "object", + "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, + "required": ["a", "b"], + "additionalProperties": False, + }, + }], + }, + }) + elif method == "tools/call": + params = request["params"] + if params.get("name") != "add" or params.get("arguments") != {"a": 19, "b": 23}: + send({ + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "unexpected tool call"}, + }) + continue + send({ + "jsonrpc": "2.0", + "id": request_id, + "result": {"content": [{"type": "text", "text": "42"}]}, + }) + else: + send({ + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"unsupported method: {method}"}, + }) +""" + + +def mcp_cordis(server_script: Path) -> str: + """Build an external config that mounts the packaged MCP client.""" + return json.dumps([ + { + "id": "sdk-jsonrpc-server", + "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", + }, + { + "id": "agent-core", + "name": "@deepseek-ai/dsh-agent-spine-demo", + "config": { + "workspaceContext": False, + "skills": {"enabled": False}, + "toolBash": False, + }, + }, + { + "id": "sessions", + "name": "@deepseek-ai/dsh-session-persistence-jsonl", + "config": {"root": "./sessions", "compression": "none"}, + }, + { + "id": "mcp-fixture", + "name": "@deepseek-ai/dsh-mcp-client", + "config": { + "serverName": "fixture", + "transport": "stdio", + "command": sys.executable, + "args": [str(server_script)], + "env": {"MCP_SMOKE_LOG": str(server_script.with_suffix(".log"))}, + "failOnStartupError": True, + "reconnect": {"enabled": False}, + }, + }, + ], indent=2) + + +def wait_for_mcp_discovery(log_path: Path) -> None: + """Wait until the external server has answered initial tool discovery.""" + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if log_path.exists() and "tools/list" in log_path.read_text().splitlines(): + # The server records the request before flushing its response; give the + # client one scheduler interval to register the returned generation. + time.sleep(0.1) + return + time.sleep(0.025) + observed = log_path.read_text() if log_path.exists() else "" + raise AssertionError(f"packaged MCP client did not complete tool discovery: {observed}") + + class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -177,6 +304,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) + mcp = mcp_tool_followup(call_id, tool_name, tool_text) + if mcp is not None: + return mcp fs_search = fs_search_tool_followup(call_id, tool_name, tool_text) if fs_search is not None: return fs_search @@ -222,6 +352,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: CODE_PROMPT, WORKFLOW_PROMPT, FS_SEARCH_PROMPT, + MCP_PROMPT, } prompt = next( (candidate for candidate in user_prompts if candidate in scenario_prompts), @@ -271,9 +402,29 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: "grep", {"pattern": FS_SEARCH_MARKER, "path": "."}, ) + if prompt == MCP_PROMPT: + assert_advertised_tool(body, "mcp__fixture__add") + return tool_call_chunks( + "mcp-add", + "mcp__fixture__add", + {"a": 19, "b": 23}, + ) return text_chunks(EXPECTED_TEXT) +def mcp_tool_followup( + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Verify one tool call through the packaged MCP client.""" + if call_id != "mcp-add": + return None + if tool_name != "mcp__fixture__add" or "42" not in tool_text: + raise AssertionError(f"packaged MCP call returned an unexpected result: {tool_name}: {tool_text}") + return text_chunks(MCP_TEXT) + + def fs_search_tool_followup( call_id: str, tool_name: str, @@ -538,7 +689,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) @@ -563,6 +714,8 @@ def main() -> None: if args.scenario in {"all", "sdk-fs-search"}: assert args.exe is not None smoke_sdk_fs_search(model.url, args.exe.resolve()) + if args.scenario in {"all", "sdk-mcp"}: + smoke_sdk_mcp(model.url, None if args.exe is None else args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -686,6 +839,42 @@ def smoke_sdk_fs_search(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, FS_SEARCH_TEXT, FS_SEARCH_MARKER, "needle.txt") +def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None: + """Discover and call an external stdio MCP tool through the packaged client.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-mcp-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + server_script = root / "mcp_server.py" + server_script.write_text(MCP_SERVER_SCRIPT) + cordis = root / "cordis.yml" + cordis.write_text(mcp_cordis(server_script)) + discovery_log = server_script.with_suffix(".log") + with DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=None if executable is None else str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + wait_for_mcp_discovery(discovery_log) + result = harness.run(MCP_PROMPT, session_id="mcp-smoke") + + assert result.final_response == MCP_TEXT, result.final_response + assert discovery_log.read_text().splitlines() == [ + "initialize", + "notifications/initialized", + "tools/list", + "tools/call", + ] + assert_session_log(sessions, root, MCP_TEXT, "mcp__fixture__add", "42") + + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" from deepseek_harness import DeepSeekHarness From ee5280bd6e73668b9e73710f01e9983bbfd36ff6 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 14:38:42 +0800 Subject: [PATCH 23/56] fix(python-sdk): harden packaged runtime behavior --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 8 ++-- ...-executable-sdk-runtime-distribution.zh.md | 8 ++-- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 6 +-- packages/fs/tool-fs-search/README.zh.md | 6 +-- packages/fs/tool-fs-search/src/search-core.ts | 2 +- .../tool-fs-search/tests/rg-sidecar.spec.ts | 45 +++++++++++++++---- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 6 +-- python/sdk-runtime/README.zh.md | 6 +-- .../src/deepseek_harness_runtime/__init__.py | 9 ++-- scripts/build-exe-for-python-sdk.ts | 2 +- scripts/cordis-yaml.ts | 30 ++++++++++++- scripts/smoke-python-runtime.py | 1 - scripts/verify-cordis-config.ts | 4 +- scripts/verify-runtime-closure.spec.ts | 45 +++++++++++++++++++ scripts/verify-runtime-closure.ts | 21 ++++++--- 18 files changed, 159 insertions(+), 52 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 7594f17605..627ce281a3 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: a09c1438d61c3ad6b92d694360a1591764f3d67e -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ce6568d7303433a5a4ca57ac76f67051944ed997 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: ecc8e1e03f5660e6cf57be05909c987bfbba750d +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3b53fb19da6668a01ac0e6b2cc0570155fb5f7e1 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index a09c1438d6..ecc8e1e03f 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -42,15 +42,15 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe with its required `-rg` sidecar and optional macOS helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-sdk-jsonrpc-server` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. @@ -64,7 +64,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, discovers its tool, and completes one model-requested call. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index ce6568d730..3b53fb19da 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -42,15 +42,15 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` sidecar;pkg 运行时通过 `process.pkg` 选择该 sidecar,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` sidecar 和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` sidecar,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、sidecar 缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-sdk-jsonrpc-server` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 @@ -64,7 +64,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread` ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` sidecar 调用 `glob` 与 `grep`。MCP 场景启动临时外部 stdio server,发现其工具,并完成一次由模型请求的调用。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index cd3819ad4d..4da9a9328d 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md -README.md: 83290df98260e977a8cd3ea808f491ea63e75857 -README.zh.md: b33fe002fc4ccc2bfe5fefec342f2044e3511078 +README.md: 84a3adc31f9c1580b90c038b0902e88b050f0340 +README.zh.md: 9f815e685eae93b7c184e529692c0c73fc8ba4bf diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 83290df982..84a3adc31f 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (`--no-config` prepended so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn; model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. +The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by a packaged ripgrep binary, not by `ctx.fs` provider methods and not by a system `rg` install. Ordinary Node deployments resolve the platform binary from `@vscode/ripgrep`; a pkg single-file runtime resolves the executable's co-located `-rg` sidecar and falls back to the dependency binary when that sidecar is absent. Registration is unconditional because both carriers package ripgrep, so there is no load-time availability probe. Each call spawns the resolved binary through the `ctx.subprocess` seam with a fixed argv vector (`--no-config` prepended so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn; model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -16,7 +16,7 @@ Why spawn-backed: local workspace discovery is naturally a process-backed `rg` w ## Deployment requirement: no host rg, co-located workdir/filesystem -The binary ships with the package on every supported platform (macOS/Linux/Windows, x64/arm64), so no host `rg` install is required and the tools register on every deployment. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. That co-location requirement carries no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. +Node deployments receive the `@vscode/ripgrep` platform package on supported macOS, Linux, and Windows x64/arm64 targets. Python SDK Linux and macOS wheels copy the target-native binary beside the single-file runtime as `-rg`; `deepseek_harness_runtime.bundled_runtime_path()` rejects an incomplete wheel before launch. No carrier requires a host `rg` install. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. That co-location requirement carries no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. ## Config @@ -129,6 +129,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. -- **The packaged binary is fixed at dependency version** — `@vscode/ripgrep` covers the platforms it ships (macOS/Linux/Windows, x64/arm64); an unsupported platform or a corrupted install fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located workspace or another search consumer. +- **The packaged binary is fixed at dependency version** — Node deployments use the version selected by `@vscode/ripgrep`; Python single-file runtimes copy that target-native version into the required `-rg` sidecar. An unsupported platform or a corrupted installation fails with `SEARCH_FAILED`, while the Python runtime package rejects a missing sidecar before launch. Remote or virtual filesystems need a co-located workspace or another search consumer. - **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend. - **Sampling, when enabled, groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred. diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index b33fe002fc..9f815e685e 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 NPM 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此不涉及 shell 引号处理),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 +**面向模型的文件系统发现工具**(`glob`、`grep`)由打包的 ripgrep 二进制支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。普通 Node 部署从 `@vscode/ripgrep` 解析平台二进制;pkg 单文件运行时解析与可执行程序共置的 `-rg` sidecar,sidecar 缺失时回退到依赖中的二进制。两种载体均打包 ripgrep,因此注册是无条件的,没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 解析出的二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此不涉及 shell 引号处理),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -16,7 +16,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ## 部署要求:无需宿主 rg,但工作目录与文件系统需共置 -二进制随包交付,覆盖所有受支持平台(macOS/Linux/Windows,x64/arm64),因此无需宿主 `rg` 安装,工具在每个部署上都注册。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。这项共置要求不附带运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区约定或特定提供方的搜索后端。 +Node 部署在受支持的 macOS、Linux 与 Windows x64/arm64 目标上获得 `@vscode/ripgrep` 平台包。Python SDK 的 Linux 与 macOS wheel 将目标原生二进制复制到单文件运行时旁,命名为 `-rg`;`deepseek_harness_runtime.bundled_runtime_path()` 会在启动前拒绝不完整的 wheel。两种载体均不要求宿主安装 `rg`。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。这项共置要求不附带运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区约定或特定提供方的搜索后端。 ## 配置 @@ -129,6 +129,6 @@ glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `g ## 已知限制与暂缓事项 - **搜索与文件访问没有共享工作区证明**——只有当工作目录与文件系统根目录指向同一工作区时,返回路径才可继续读取;本包不执行运行时跨服务校验。 -- **打包二进制固定在依赖版本上**——`@vscode/ripgrep` 覆盖其随附的平台(macOS/Linux/Windows,x64/arm64);不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 +- **打包二进制固定在依赖版本上**——Node 部署使用 `@vscode/ripgrep` 选择的版本;Python 单文件运行时将对应目标的原生版本复制为必需的 `-rg` sidecar。不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败,Python 运行时包则会在启动前拒绝缺失 sidecar 的安装。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 - **schema 只暴露一个有界页面**——偏移分页、大小写开关、替代输出模式与提供方支撑的发现仍不在本包范围内;达到上限的完整输出需要 spill 后端。 - **启用采样时仅按搜索根正下方的第一段路径分组**——超过上限的 `glob` 页面在这些顶层条目之间平衡,因此集中在更深处的结果(一棵均匀树里某个繁忙目录)在该层级之下仍会呈现不均;递归平衡被延期。 diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index e4580f7301..5ac5521033 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -171,7 +171,7 @@ let rgPathPromise: Promise | undefined export function resolveRgPath(): Promise { rgPathPromise ??= Promise.resolve().then(async () => { const executableSidecar = `${process.execPath}-rg` - if (existsSync(executableSidecar)) return executableSidecar + if ('pkg' in process && existsSync(executableSidecar)) return executableSidecar return (await import('@vscode/ripgrep')).rgPath }) return rgPathPromise diff --git a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts index 49cca6bfa8..53c7a01aea 100644 --- a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts +++ b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts @@ -1,25 +1,52 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const existsSync = vi.hoisted(() => vi.fn(() => true)) +const { dependencyRgPath, existsSync } = vi.hoisted(() => ({ + dependencyRgPath: '/node_modules/@vscode/ripgrep/bin/rg', + existsSync: vi.fn(), +})) vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, existsSync } }) -vi.mock('@vscode/ripgrep', () => new Proxy({}, { - get() { - throw new Error('the platform package must not load when the executable sidecar exists') - }, -})) +vi.mock('@vscode/ripgrep', () => ({ rgPath: dependencyRgPath })) -import { resolveRgPath } from '@deepseek-ai/dsh-tool-fs-search' +beforeEach(() => { + vi.resetModules() + existsSync.mockReset() + Reflect.deleteProperty(process, 'pkg') +}) -describe('single-executable ripgrep resolution', () => { +afterEach(() => { + Reflect.deleteProperty(process, 'pkg') +}) + +describe('ripgrep resolution', () => { it('uses the native sidecar beside the current executable', async () => { + Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + existsSync.mockReturnValue(true) const sidecar = `${process.execPath}-rg` + const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') await expect(resolveRgPath()).resolves.toBe(sidecar) expect(existsSync).toHaveBeenCalledWith(sidecar) }) + + it('uses the dependency binary in an ordinary Node process', async () => { + existsSync.mockReturnValue(true) + const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') + + await expect(resolveRgPath()).resolves.toBe(dependencyRgPath) + expect(existsSync).not.toHaveBeenCalled() + }) + + it('uses the dependency binary when a packaged runtime has no sidecar', async () => { + Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + existsSync.mockReturnValue(false) + const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') + + await expect(resolveRgPath()).resolves.toBe(dependencyRgPath) + expect(existsSync).toHaveBeenCalledWith(`${process.execPath}-rg`) + }) }) diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index d04296dd33..96739c3baa 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: c00357dbad74f8de705789bce8e4a55cc5fd67b1 -README.zh.md: a62edb4cd98338d22332f8e124c3ecb3f2356ace +README.md: 597d69a803a7cd1204fd48456f8e1ba18d9786e5 +README.zh.md: 888ecdad437d001b84b1af71c04e2da016d3901e diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index c00357dbad..597d69a803 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) with a target-native ripgrep `-rg` sidecar. macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. @@ -17,12 +17,12 @@ The bundled plugin set includes `@deepseek-ai/dsh-mcp-client`, so an external Co A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. +Each wheel contains exactly one runtime executable and its matching ripgrep `-rg` sidecar. The macOS wheel also contains its matching native spawn helper; any missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use filesystem-search or PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime executables, missing or extra sidecars, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. ## Resolution API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. -- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; on macOS it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above. +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only); it validates the required sibling `-rg` sidecar on every platform and the `-spawn-helper` sidecar on macOS. The node carrier has no single-path equivalent and launches via the argv tuple above. - `bundled_default_config_path() -> Path` — the checked-in default config (see below). - `bundled_package_dir() -> Path` — the installed package data root. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index a62edb4cd9..888ecdad43 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),以及匹配目标平台的 ripgrep `-rg` sidecar。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 @@ -17,12 +17,12 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的 ripgrep `-rg` sidecar。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少任一 sidecar 都意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用文件系统搜索或 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在或存在多个运行时可执行文件、缺失或多余的 sidecar、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会在 macOS 上校验必要的 `-spawn-helper` 伴随文件也已安装)。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体);它会在所有平台校验必要的 `-rg` sidecar,并在 macOS 上额外校验 `-spawn-helper` sidecar。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 727ee29586..a17a57ca70 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -71,10 +71,11 @@ def bundled_runtime_path() -> Path: """Absolute path of the bundled single-file runtime executable for the current platform. Raises FileNotFoundError when the platform is unsupported, the executable - has not been placed into this package, or the required macOS spawn helper is - missing; the message names the acquisition routes (acquisition strategy is - deliberately separate from this lookup interface, so an on-demand download - can replace it without touching callers). + has not been placed into this package, the required ripgrep sidecar is + missing, or the required macOS spawn helper is missing; the message names + the acquisition routes (acquisition strategy is deliberately separate from + this lookup interface, so an on-demand download can replace it without + touching callers). """ tag = _current_platform_tag() path = bundled_package_dir() / "runtime" / f"dsh-jsonrpc-agent-pkg-{tag}" diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index ae35802cc9..4773a506b0 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -378,7 +378,7 @@ class SingleExeBuild { /** * Package one target; SEA mode accepts one target per invocation. * @param target - the pkg target triple to build. - * @returns the executable path and, on macOS, its helper path. + * @returns the executable and ripgrep sidecar paths, plus the macOS spawn helper path when required. */ async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) diff --git a/scripts/cordis-yaml.ts b/scripts/cordis-yaml.ts index 60cd492787..e7db335c6a 100644 --- a/scripts/cordis-yaml.ts +++ b/scripts/cordis-yaml.ts @@ -1,5 +1,11 @@ +/** + * Cordis YAML parsing and Loader-entry classification shared by repository checks. + * @module scripts/cordis-yaml + */ + import * as yaml from 'js-yaml' +/** A Loader `!!js` expression preserved as data instead of executed. */ export interface JsExpr { __jsExpr: string } @@ -14,13 +20,35 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { }) const schema = yaml.JSON_SCHEMA.extend(jsExprType) -/** Parse a Cordis config while preserving Loader `!!js` expressions as data. */ +/** + * Parse a Cordis config while preserving Loader `!!js` expressions as data. + * @param source - Cordis YAML source text. + * @returns the parsed YAML value. + */ export function loadCordisYaml(source: string): unknown { return yaml.load(source, { schema }) } +/** + * Test whether a value is a preserved Loader `!!js` expression. + * @param value - parsed YAML value. + * @returns whether the value contains one preserved expression. + */ export function isJsExpr(value: unknown): value is JsExpr { return typeof value === 'object' && value !== null && typeof (value as Record).__jsExpr === 'string' } + +/** + * Test whether a Loader entry owns nested entries in its `config` array. + * @param value - parsed Loader entry. + * @returns whether the entry is an explicit or package-named Cordis group. + */ +export function isCordisGroupEntry(value: unknown): value is Record & { config: unknown[] } { + return typeof value === 'object' + && value !== null + && Array.isArray((value as Record).config) + && ((value as Record).group === true + || (value as Record).name === '@deepseek-ai/cordis-plugin-group') +} diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 76f3e4f60a..b478efe5e0 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -29,7 +29,6 @@ WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor." MINIMAL_TEXT = "minimal agent smoke ok" MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " -MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools." FS_SEARCH_TEXT = "filesystem search smoke ok" FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK" diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 9777979f5f..d47ce395b8 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -15,7 +15,7 @@ import { dirname, relative, resolve } from 'node:path' import { Script } from 'node:vm' import ts from 'typescript' import { cordisConfigFiles } from './cordis-config-files.ts' -import { isJsExpr, loadCordisYaml } from './cordis-yaml.ts' +import { isCordisGroupEntry, isJsExpr, loadCordisYaml } from './cordis-yaml.ts' interface PackageManifest { name?: string @@ -191,7 +191,7 @@ function validateEntry(value: unknown, file: string, path: string): void { } recordPlugin(value, file) validateMetadata(value, file, path) - if ((value.group === true || value.name === '@deepseek-ai/cordis-plugin-group') && isUnknownArray(value.config)) { + if (isCordisGroupEntry(value)) { for (let index = 0; index < value.config.length; index++) { validateEntry(value.config[index], file, `${path}.config[${index}]`) } diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index ccce67c498..50d97e0dae 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -82,6 +82,51 @@ describe('verifyRuntimeClosure', () => { ]) }) + it('does not interpret an ordinary plugin array config as nested Loader entries', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': 'workspace:^' } }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` +- id: plugin + name: '@scope/plugin' + config: + - name: '@scope/config-value' +`, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.failures).toEqual([]) + }) + + it('fails when no shipped preset is discovered', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, + 'python/sdk-runtime/platforms.json': platforms, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.presetCount).toBe(0) + expect(result.failures).toEqual([ + 'no agent presets matched apps/cli/config/agent-presets/*/agent.cordis.yml', + ]) + }) + + it('fails when the runtime platform manifest has no targets', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, + 'python/sdk-runtime/platforms.json': {}, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': '[]\n', + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.failures).toEqual([ + 'python/sdk-runtime/platforms.json defines no runtime targets', + ]) + }) + it('retains the required workspace-peer closure check', async () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/root': 'workspace:^' } }, diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index c62ef68e37..549c06e9ad 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -8,7 +8,7 @@ import { globSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import { parseArgs } from 'node:util' -import { loadCordisYaml } from './cordis-yaml.ts' +import { isCordisGroupEntry, loadCordisYaml } from './cordis-yaml.ts' interface PackageManifest { name?: string @@ -30,6 +30,8 @@ interface RuntimePlatform { type RuntimePlatformManifest = Record +const AGENT_PRESET_GLOB = 'apps/cli/config/agent-presets/*/agent.cordis.yml' + export interface RuntimeClosureResult { failures: string[] presetCount: number @@ -51,6 +53,8 @@ export async function verifyRuntimeClosure( const workspace = await loadWorkspacePackages(root) const runtimeDependencies = runtimeManifest.dependencies ?? {} const platforms = await loadJson(resolve(root, 'python/sdk-runtime/platforms.json')) + const presetPaths = globSync(AGENT_PRESET_GLOB, { cwd: root }).sort() + const targets = Object.keys(platforms).sort() const parents = new Map() const queue: string[] = [] @@ -60,7 +64,10 @@ export async function verifyRuntimeClosure( queue.push(dependency) } - const failures = await missingPresetPlugins(root, runtimeDependencies, platforms) + const failures: string[] = [] + if (presetPaths.length === 0) failures.push(`no agent presets matched ${AGENT_PRESET_GLOB}`) + if (targets.length === 0) failures.push('python/sdk-runtime/platforms.json defines no runtime targets') + failures.push(...await missingPresetPlugins(root, runtimeDependencies, presetPaths, targets)) for (let index = 0; index < queue.length; index += 1) { const packageName = queue[index] if (packageName === undefined) continue @@ -86,7 +93,7 @@ export async function verifyRuntimeClosure( return { failures, - presetCount: globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length, + presetCount: presetPaths.length, workspacePackageCount: queue.length, } } @@ -112,18 +119,18 @@ if (import.meta.main) { async function missingPresetPlugins( root: string, runtimeDependencies: Readonly>, - platforms: RuntimePlatformManifest, + presetPaths: readonly string[], + targets: readonly string[], ): Promise { const missing = new Map>() const failures: string[] = [] - const presetPaths = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).sort() for (const presetPath of presetPaths) { const document = loadCordisYaml(await readFile(resolve(root, presetPath), 'utf8')) if (!Array.isArray(document)) { failures.push(`${presetPath}: preset root must be a Loader entry array`) continue } - for (const target of Object.keys(platforms).sort()) { + for (const target of targets) { const processPlatform = processPlatformForTarget(target) for (const plugin of activeBarePluginPackages(document, processPlatform)) { if (runtimeDependencies[plugin] !== undefined) continue @@ -150,7 +157,7 @@ function activeBarePluginPackages(entries: unknown[], processPlatform: string): const packageName = barePackageName(value.name) if (packageName !== undefined) packages.add(packageName) } - if (Array.isArray(value.config)) { + if (isCordisGroupEntry(value)) { for (const child of value.config) visit(child, disabled) } } From 51fa8da8a3fc2748a92821e538ba63ceae81cff8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 18 Aug 2026 15:43:59 +0800 Subject: [PATCH 24/56] fix(plan): accept image-only plan requests --- ...ommand-image-attachment-envelope.i18n.yaml | 4 +- ...08-17-command-image-attachment-envelope.md | 4 +- ...17-command-image-attachment-envelope.zh.md | 4 +- .../tests/command-image-envelope.snapshot.ts | 23 +++++++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/plan.i18n.yaml | 4 +- docs/subsystems/plan.md | 2 +- docs/subsystems/plan.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 26 ++++++--- .../tests/fixture-commands.client.spec.ts | 19 +++--- packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 8 +-- packages/plan/plan-mode/README.zh.md | 8 +-- packages/plan/plan-mode/src/index.ts | 58 +++++++++++-------- packages/plan/plan-mode/src/types.ts | 9 +-- .../plan/plan-mode/tests/plan-mode.spec.ts | 23 ++++++-- .../plan/plan-mode/tests/projection.spec.ts | 36 ++++++++---- 22 files changed, 156 insertions(+), 94 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml index fbce9616aa..56eafca036 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md -2026-08-17-command-image-attachment-envelope.md: f651658ef0b655d6d190d25f54c2afaa088ad7be -2026-08-17-command-image-attachment-envelope.zh.md: dcf2f72feb6dc4c1a158acd6e66924f3c46f1b3e +2026-08-17-command-image-attachment-envelope.md: 328a3fffa1d8db3ac9be42983965ef7f9578dec9 +2026-08-17-command-image-attachment-envelope.zh.md: bb135d218f156aaa36e3f9f52ed36019b68b56c3 diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md index f651658ef0..328a3fffa1 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md @@ -20,7 +20,7 @@ The submission envelope is modeled end to end, and every command route either co **Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`. -**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan` folds the images into the message it already steers. Both producers reject sub-commands whose grammar has no carrier (`/goal pause`, bare `/plan`, `/plan off`) with a direct error, which keeps the composer's images in place. +**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan ` folds the images into its steered text message, while bare `/plan` steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (`/goal pause`, `/plan off`) return a direct error and keep the composer's images in place. The plan projection treats `command/run` as a candidate and drops it on a paired `command/done` error, so a rejected image-carrying `/plan off` cannot leave a pending exit. **Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them. @@ -41,6 +41,6 @@ Registry executor enforcement, admission failure settlement, and frozen invocati - No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike. - The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly. -- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), billed like any image prompt. +- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare `/plan`; all are billed like any image prompt. - Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary. - "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written". diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md index dcf2f72feb..bb135d218f 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md @@ -20,7 +20,7 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片 **执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。 -**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan` 把图片并入它本就要 steer 的消息。两个生产方都会拒绝语法上没有载体的子命令(`/goal pause`、不带参数的 `/plan`、`/plan off`),直接返回错误,composer 的图片原地保留。 +**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan ` 把图片并入其 steer 的文本消息;不带参数的 `/plan` 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(`/goal pause`、`/plan off`)会直接返回错误,composer 的图片原地保留。plan 投影会把 `command/run` 视为候选选择,并在配对的 `command/done` 报错时丢弃它,因此被拒绝的带图 `/plan off` 不会留下待退出状态。 **composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。 @@ -41,6 +41,6 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片 - 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。 - commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。 -- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),计费与任何图片提示词相同。 +- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的 `/plan` 会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。 - 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。 - 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。 diff --git a/apps/web/tests/command-image-envelope.snapshot.ts b/apps/web/tests/command-image-envelope.snapshot.ts index f0a958f5cc..20aeb3a040 100644 --- a/apps/web/tests/command-image-envelope.snapshot.ts +++ b/apps/web/tests/command-image-envelope.snapshot.ts @@ -3,9 +3,10 @@ // bundles via AppWebEntry, keyless FixtureApiClient transport): an enter // submission carrying composer images resolves only through a command whose // descriptor declares `input.images`. A non-declaring command refuses with -// one composer error banner and everything retained; a declaring command consumes -// the images — serialized through the real draft-image chain into the -// commands/execute payload — and clears the composer on success. +// one composer error banner and everything retained; a declaring command +// consumes the images — serialized through the real draft-image chain into +// the commands/execute payload — and clears the composer on success, including +// when the image is the whole `/plan` task. import { fireEvent, screen, waitFor } from '@testing-library/react' import { expect, it } from 'vitest' import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' @@ -78,3 +79,19 @@ it('consumes images through a declaring command and clears the composer on succe expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() }, { timeout: 5_000 }) }) + +it('submits a bare /plan with an image as an image-only plan request', async () => { + mountAssembledApp() + const textarea = await freshComposer() + await pasteImage(textarea, 'plan-task.png') + + fireEvent.change(textarea, { target: { value: '/plan' } }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + await waitFor(() => { + expect(textarea.value).toBe('') + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + }, { timeout: 5_000 }) + expect([...document.querySelectorAll('[role="alert"]')] + .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false) +}) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 658a54a3a7..aded6f2e89 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6759d4b8e953c2a147a0441be21a25e0920cc1df -config-catalog.zh.md: e59ad1ac07f46b7cb044a83012aa97d1a78f769b +config-catalog.md: fc14407107212b2b5ad4209cc755d7d586c066d0 +config-catalog.zh.md: 5c2f5048d4ba42d19418969bc8be828850b42618 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6759d4b8e9..fc14407107 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1372,7 +1372,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:71`](../packages/plan/plan-mode/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e59ad1ac07..5c2f5048d4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1374,7 +1374,7 @@ export interface PlanModeConfig { } ``` -来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:71`](../packages/plan/plan-mode/src/index.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index bd75b9516c..15c064b8cb 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: c78c6c9b7c116b5ea545a6ecb6e0f5c9013a53a7 -persistence-catalog.zh.md: b787c8c30e0d695246db15372b639bd5bde44c07 +persistence-catalog.md: d290d40ade2773e591cf07235a64a150b52bdcd2 +persistence-catalog.zh.md: a2340f1226a01a8a797d576d64a667ce2fa58da6 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c78c6c9b7c..d290d40ade 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -527,7 +527,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/ 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index b787c8c30e..a2340f1226 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -529,7 +529,7 @@ export type SessionEvent = { 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 857839b0c5..4623b14448 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/plan.md -plan.md: 4e6eb98e7c7cce295feeed0150984934f1a853e5 -plan.zh.md: f8236e6cbeca841bdab630aa831e844cc68179a0 +plan.md: 1f6863a24aa56773430be904e5a27c27384c9bff +plan.zh.md: 056bce946b608876ac958f2d33d871e9622c7187 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 4e6eb98e7c..1f6863a24a 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index f8236e6cbe..056bce946b 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e8fa9ebbd5..f786efeaf5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -742,26 +742,34 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi } /** - * Fixture parallel of the plan unit's double-event fold: `command/run` - * records named `plan` with recorded input set the wanted target (`off` → - * false, else true); `plan/mode` commits and clears it. `wanted` is exposed - * for the prompt boundary (the fixture's step/start parallel). + * Fixture parallel of the plan unit's lifecycle fold. The paired + * `command/done` retains successful plan selections and drops failures; + * `plan/mode` commits one. `wanted` is exposed for the prompt boundary (the + * fixture's step/start parallel). */ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { let active = false let wanted: boolean | null = null + let running: { commandId: unknown; wanted: boolean } | null = null for (const event of log) { const item = event as unknown as { type: string; data?: Record } if (item.type === 'command/run' && item.data?.['name'] === 'plan') { const args = item.data['args'] if (typeof args !== 'string') continue - wanted = args.trim() !== 'off' + running = { commandId: item.data['commandId'], wanted: args.trim() !== 'off' } + } else if (item.type === 'command/done' + && item.data !== undefined + && running !== null + && item.data['commandId'] === running.commandId) { + wanted = item.data['kind'] === 'success' && running.wanted !== active ? running.wanted : null + running = null } else if (item.type === 'plan/mode') { active = item.data?.['active'] === true wanted = null } } - return { active, pending: wanted !== null && wanted !== active, wanted } + const selected = running?.wanted ?? wanted + return { active, pending: selected !== null && selected !== active, wanted: selected } } /** The plan projection's wire view over the full log. */ @@ -1752,7 +1760,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // executor's order (an unknown name answers undefined and logs no // lifecycle): the declaration rejection covers every known command // without `input.images`, and the two producer grammar rejections cover - // the declaring commands' carrier-less lines. The fixture stores no + // the declaring commands' control-only lines. The fixture stores no // bytes, so an accepted batch is acknowledged and dropped. const known = ['permission', 'goal', 'compact', 'echo', 'plan'] if (images.length > 0 && name !== undefined && known.includes(name)) { @@ -1760,8 +1768,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ? `/${name} does not accept image attachments` : name === 'goal' && args.trim() === '' ? 'Image attachments only accompany a goal objective: /goal or /goal edit .' - : name === 'plan' && (args.trim() === '' || args.trim() === 'off') - ? 'Image attachments require a plan message: /plan .' + : name === 'plan' && args.trim() === 'off' + ? 'Image attachments cannot accompany /plan off.' : undefined if (rejection !== undefined) { const commandId = `fx-cmd-${logOf(id).length}` as CommandId diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index c1c93d5c14..62118062b5 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -117,9 +117,12 @@ describe('createFixtureApi commands/skills', () => { const planMessage = await callRemote<{ result: { kind: string } } | undefined>( rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan sketch the layout', images: [png] }) expect(planMessage?.result.kind).toBe('success') + const imageOnlyPlan = await callRemote<{ result: { kind: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan', images: [png] }) + expect(imageOnlyPlan?.result.kind).toBe('success') }) - it('mirrors the producer grammar rejections for carrier-less declaring lines', async () => { + it('mirrors the producer grammar rejections for control-only declaring lines', async () => { const { rpc } = createFixtureFaces() const png = { mediaType: 'image/png', data: 'AA==' } const bareGoal = await callRemote<{ result: { kind: string; text?: string } } | undefined>( @@ -128,14 +131,12 @@ describe('createFixtureApi commands/skills', () => { kind: 'error', text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', }) - for (const line of ['/plan', '/plan off']) { - const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line, images: [png] }) - expect(refused?.result).toEqual({ - kind: 'error', - text: 'Image attachments require a plan message: /plan .', - }) - } + const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] }) + expect(refused?.result).toEqual({ + kind: 'error', + text: 'Image attachments cannot accompany /plan off.', + }) }) it('answers no execution for an unknown name even when images accompany it', async () => { diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 5818d40a4b..c12c5c248f 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md -README.md: 67783a9369339005ba748d5cfa929ceb6fef4a70 -README.zh.md: 28d505f5f2591de9774c0e5f6412d5570a81163a +README.md: 3eabe2cb3f04b434b7f908f7beca869f1022a59e +README.zh.md: f7d6a1f8e9f5ba95f8aad9457f3dde5fc415fdcf diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 67783a9369..3eabe2cb3f 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -16,13 +16,13 @@ While active, `plan:policy` renders the configured `section`. The plugin always The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message. -When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. The command declares `input.images`: composer image attachments ride the steered message ahead of its text block, and an invocation whose attachments have no message carrier (`/plan` or `/plan off`) returns a direct error before any mode change so the composer keeps the images. +When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. The command declares `input.images`: composer image attachments ride the steered message ahead of its text block. Bare `/plan` with images steers an image-only user message, while `/plan off` with images returns a direct error before any mode change so the composer keeps them. The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary. ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, so a failed handler cannot leave a recorded command without its plan selection). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. +When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection. Every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. This remains a pure replay quantity, so host restarts, other tabs, and cold reads recover it from the log alone, and a rejected `/plan off` with images cannot leave a pending exit. The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. ## Configuration @@ -65,11 +65,11 @@ The section is stable within plan mode, but entering or leaving changes the syst #### What the model sees -`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. +`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. Bare `/plan` with admitted images steers one user message containing only those image blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. #### Token effect -The optional message costs the same history tokens as submitting that text separately; bare `/plan` and `/plan off` add none. A narrated active exit adds the small retained switch notice. +The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without images and `/plan off` add none; bare `/plan` with images has the normal image-prompt cost. A narrated active exit adds the small retained switch notice. #### KV Cache effect diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 28d505f5f2..f7d6a1f8e9 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -16,13 +16,13 @@ 评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅——用户关闭请求,转而发言——会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。 -组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。该命令声明了 `input.images`:composer 图片附件会随被 steer 的消息一起提交,位于文本块之前;附件没有消息载体的调用(`/plan` 或 `/plan off`)会在任何模式变更前直接返回错误,composer 保留图片。 +组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。该命令声明了 `input.images`:composer 图片附件会随被 steer 的消息一起提交,位于文本块之前。不带参数的 `/plan` 若附有图片,会 steer 一条只含图片的用户消息;`/plan off` 若附有图片,会在任何模式变更前直接返回错误,composer 保留图片。 Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,因此处理器失败时不会留下缺少对应 plan 选择的已记录命令。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值仍完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 ## 配置 @@ -65,11 +65,11 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型所见内容 -`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一条用户消息:任何已准入的图片附件作为前置图片块,之后是已去除首尾空白的文本块。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。 +`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一条用户消息:任何已准入的图片附件作为前置图片块,之后是已去除首尾空白的文本块。不带参数的 `/plan` 若带有已准入图片,会 steer 一条只含这些图片块的用户消息。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。 #### Token 影响 -可选消息的历史 token 成本与单独提交该文本相同;不带参数的 `/plan` 和 `/plan off` 不增加 token。一次带有切换通知的已激活状态退出会追加一条简短且会保留的通知。 +可选消息的历史 token 成本与单独提交该内容相同。不带图片和参数的 `/plan` 与 `/plan off` 不增加 token;不带参数但带图的 `/plan` 产生常规图片提示词成本。一次带有切换通知的已激活状态退出会追加一条简短且会保留的通知。 #### KV Cache 影响 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index e3eb4dd105..b3d0933256 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -34,6 +34,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' // Type-only edge: resolves `ctx.commands` for the optional command child. import type {} from '@deepseek-ai/dsh-commands' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import type { PlanProjection } from './types.ts' @@ -138,14 +139,17 @@ export function foldPlanMode(events: readonly SessionEvent[], end = events.lengt } /** - * Projection unit state: the logged mode plus the latest logged `/plan` - * selection (`command/run`) not yet resolved by a `plan/mode` commit. Plain - * JSON (persisted-cache precondition). + * Projection unit state: the logged mode, the latest successful `/plan` + * selection not yet resolved by a `plan/mode` commit, and an execution whose + * paired `command/done` has not settled. Plain JSON (persisted-cache + * precondition). */ interface PlanUnitState { active: boolean /** The selection's target mode; null when no selection is outstanding. */ wanted: boolean | null + /** The latest plan command awaiting its paired settlement. */ + running: { commandId: CommandId; wanted: boolean } | null } /** Wire payload schema of the `plan` projection. */ @@ -232,12 +236,11 @@ export class PlanModeController extends Service { }, }) - // The plan projection unit (session-projection RFC): a pure double-event - // fold serving clients the whole {active, pending} value. `command/run` - // records the user's logged /plan selection (the handler calls `set()` - // before any failing path, so a failed handler cannot leave the recorded - // command without its plan selection); `plan/mode` records that selection - // and clears it. Pending is thereby a pure + // The plan projection unit (session-projection RFC): a pure event fold + // serving clients the whole {active, pending} value. `command/run` + // records the user's logged /plan selection, its paired `command/done` + // keeps only successful selections, and `plan/mode` records that + // selection and clears it. Pending is thereby a pure // replay quantity: host restarts, other tabs, and cold reads all recover // it from the log alone. The unit child activates only when a projection // registry is composed (headless assemblies stay unaffected). @@ -245,23 +248,29 @@ export class PlanModeController extends Service { projectionCtx.sessionProjections.register<'plan', PlanUnitState>({ key: 'plan', schema: planProjectionSchema, - init: () => ({ active: false, wanted: null }), + init: () => ({ active: false, wanted: null, running: null }), apply: (state, event) => { if (event.type === 'command/run' && event.data.name === 'plan') { if (event.data.args === undefined) return state const wanted = event.data.args.trim() !== 'off' - return wanted === state.wanted ? state : { active: state.active, wanted } + return { ...state, running: { commandId: event.data.commandId, wanted } } + } + if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) { + const wanted = event.data.kind === 'success' && state.running.wanted !== state.active + ? state.running.wanted + : null + return { ...state, wanted, running: null } } if (event.type === 'plan/mode') { - return { active: event.data.active, wanted: null } + return { ...state, active: event.data.active, wanted: null } } return state }, - view: state => ({ - active: state.active, - pending: state.wanted !== null && state.wanted !== state.active, - }), - stateVersion: 1, + view: (state) => { + const wanted = state.running?.wanted ?? state.wanted + return { active: state.active, pending: wanted !== null && wanted !== state.active } + }, + stateVersion: 2, }) }) @@ -273,12 +282,8 @@ export class PlanModeController extends Service { input: { hint: '[off|message]', images: true }, handler: ({ agent, rawInput, attachments }) => { const message = rawInput.trim() - if (message === 'off' || message === '') { - // Attachments ride the steered message; without one they have no - // model-visible carrier, so the composer must keep them. - if (attachments.length > 0) { - return { kind: 'error', text: 'Image attachments require a plan message: /plan .' } - } + if (message === 'off' && attachments.length > 0) { + return { kind: 'error', text: 'Image attachments cannot accompany /plan off.' } } if (message === 'off') { switch (this.set(agent, false)) { @@ -298,9 +303,12 @@ export class PlanModeController extends Service { } } const outcome = this.set(agent, true) - if (message !== '') { + if (message !== '' || attachments.length > 0) { agent.steer(createUserMessage({ - content: [...attachments, { type: 'text', text: message }], + content: [ + ...attachments, + ...(message === '' ? [] : [{ type: 'text' as const, text: message }]), + ], source: { kind: 'user' }, })) } diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index a3c10d2252..8efd32f22a 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -11,9 +11,10 @@ /** * The plan projection's wire value. `active` is the logged state in force * (the last `plan/mode`, inactive before the first); `pending` is true while - * a logged `/plan` selection (`command/run`) targets a state other than - * `active` and no later `plan/mode` event has recorded that state. Capability - * absence (plan-mode not composed) is the key's absence, never a value. + * a logged `/plan` selection targets a state other than `active`, has not + * failed through its paired `command/done`, and no later `plan/mode` event has + * recorded that state. Capability absence (plan-mode not composed) is the + * key's absence, never a value. */ export interface PlanProjection { active: boolean @@ -22,7 +23,7 @@ export interface PlanProjection { declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { - /** Plan collaboration state folded from `command/run` (name `plan`) and `plan/mode` events. */ + /** Plan collaboration state folded from the plan command lifecycle and `plan/mode` events. */ plan: PlanProjection } } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index bedcb944c1..8285147953 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -645,7 +645,7 @@ describe('/plan', () => { expect(foldPlanMode(agent.session.events)).toBe(false) }) - it('rides image attachments on the steered plan message and refuses carriers without one', async () => { + it('steers image attachments with or without text and refuses them on /plan off', async () => { const ctx = await setup() await ctx.plugin(CommandRuntime) await new Promise(resolve => setImmediate(resolve)) @@ -693,11 +693,22 @@ describe('/plan', () => { const bareSteer = vi.fn() ;(bareAgent as unknown as { steer: typeof bareSteer }).steer = bareSteer expect((await ctx.commands.execute(bareAgent, '/plan', images, signal))?.result) - .toEqual({ kind: 'error', text: 'Image attachments require a plan message: /plan .' }) - expect((await ctx.commands.execute(bareAgent, '/plan off', images, signal))?.result) - .toEqual({ kind: 'error', text: 'Image attachments require a plan message: /plan .' }) - expect(bareSteer).not.toHaveBeenCalled() - expect(ctx.planMode.get(bareAgent)).toEqual({ active: false }) + .toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.' }) + expect(bareSteer).toHaveBeenCalledExactlyOnceWith({ + id: expect.any(String) as unknown, + role: 'user', + content: [{ type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }], + source: { kind: 'user' }, + }) + expect(ctx.planMode.get(bareAgent)).toEqual({ active: false, pending: true }) + + const activeAgent = await agentWithSession(ctx, 'imaged-off-plan-command', { active: true }) + const offSteer = vi.fn() + ;(activeAgent as unknown as { steer: typeof offSteer }).steer = offSteer + expect((await ctx.commands.execute(activeAgent, '/plan off', images, signal))?.result) + .toEqual({ kind: 'error', text: 'Image attachments cannot accompany /plan off.' }) + expect(offSteer).not.toHaveBeenCalled() + expect(ctx.planMode.get(activeAgent)).toEqual({ active: true }) }) it('removes the contributed command when the plan-mode plugin is disposed', async () => { diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 25f9a5197c..e0ad20148c 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -1,9 +1,10 @@ /** * The `plan` projection unit (session-projection RFC's complete example): a - * double-event fold over the session log. `command/run` records named `plan` - * with recorded input set the wanted target (`off` → false, anything else - * → true); `plan/mode` commits and clears it. `view` reports pending only - * while an outstanding selection differs from the logged state. + * event fold over the session log. `command/run` records named `plan` with + * recorded input set the candidate target (`off` → false, anything else → + * true); `command/done` keeps successful candidates and drops failures; + * `plan/mode` commits and clears a selection. `view` reports pending only while + * an outstanding selection differs from the logged state. * Pending is thereby a pure replay quantity — a cold fold answers it without * the service's in-memory intent. Composition without plan-mode has no `plan` * key; unloading the fiber removes it (HMR safety). @@ -47,13 +48,20 @@ async function harness(withPlanMode: boolean): Promise { } /** Append one logged /plan selection record (the executor's command/run shape). */ -function runPlanCommand(session: Session, args: string, index: number): void { +function runPlanCommand(session: Session, args: string, index: number): CommandId { + const commandId = CommandId(`plan-proj-${String(index)}`) session.append('command/run', { - commandId: CommandId(`plan-proj-${String(index)}`), + commandId, name: 'plan', args, source: { kind: 'user' }, }) + return commandId +} + +/** Append the paired settlement for one projected plan command. */ +function settlePlanCommand(session: Session, commandId: CommandId, kind: 'success' | 'error'): void { + session.append('command/done', { commandId, kind }) } /** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */ @@ -71,15 +79,23 @@ describe('plan projection unit', () => { it('a logged /plan selection reads pending until plan/mode records it', async () => { const bench = await harness(true) - runPlanCommand(bench.session, '', 0) + const commandId = runPlanCommand(bench.session, '', 0) expect(bench.values().plan).toEqual({ active: false, pending: true }) - // A repeated identical selection returns the same state reference (no frame). - runPlanCommand(bench.session, '', 1) + settlePlanCommand(bench.session, commandId, 'success') expect(bench.values().plan).toEqual({ active: false, pending: true }) commitPlanMode(bench.session, true, 0) expect(bench.values().plan).toEqual({ active: true, pending: false }) }) + it('drops a plan selection when its command settles with an error', async () => { + const bench = await harness(true) + commitPlanMode(bench.session, true, 0) + const commandId = runPlanCommand(bench.session, 'off', 0) + expect(bench.values().plan).toEqual({ active: true, pending: true }) + settlePlanCommand(bench.session, commandId, 'error') + expect(bench.values().plan).toEqual({ active: true, pending: false }) + }) + it('folds `off` args and non-plan commands correctly, and a matching selection is not pending', async () => { const bench = await harness(true) commitPlanMode(bench.session, true, 0) @@ -128,7 +144,7 @@ describe('plan projection unit', () => { // memory involved, the fold alone answers {active:false, pending:true}. const cold = await harness(true) for (const event of bench.session.events) { - if (event.type === 'command/run' || event.type === 'plan/mode') { + if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') { cold.session.append(event.type, event.data) } } From bd1083d78a7125038162ae4f570a123fad28cc03 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 18 Aug 2026 17:06:42 +0800 Subject: [PATCH 25/56] test(commands): align image dimension limits --- packages/interaction/commands/tests/commands.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 900ef2cc74..755bb0ab2f 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -473,7 +473,7 @@ describe('image attachments', () => { const store = { imageLimits: { maxImageBytes: 1024, maxImagesPerMessage: 2, maxMessageImageBytes: 1024, - maxImagePixels: 1_000_000, mediaTypes: ['image/png'], + maxImagePixels: 1_000_000, maxImageDimension: 2000, mediaTypes: ['image/png'], }, validateImage: vi.fn(() => Promise.resolve()), saveImage: vi.fn((input: { mediaType: string; name?: string }) => { From 96442bd4e54ee5339a48fbc157c6f919b24ae97e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 18 Aug 2026 17:06:46 +0800 Subject: [PATCH 26/56] test(subprocess): publish exit fixture state atomically --- .../subprocess-local/tests/fixtures/managed-tree.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts index 31d26b9e39..a949e5fbfa 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { writeFile } from 'node:fs/promises' +import { rename, writeFile } from 'node:fs/promises' const [statePath] = process.argv.slice(2) if (statePath === undefined) throw new Error('usage: managed-tree.ts ') @@ -12,5 +12,7 @@ const descendant = spawn(process.execPath, [ ], { stdio: 'ignore' }) if (descendant.pid === undefined) throw new Error('managed descendant did not publish a pid') -await writeFile(statePath, JSON.stringify({ root: process.pid, descendant: descendant.pid })) +const pendingStatePath = `${statePath}.pending-${process.pid}` +await writeFile(pendingStatePath, JSON.stringify({ root: process.pid, descendant: descendant.pid })) +await rename(pendingStatePath, statePath) setInterval(() => {}, 60_000) From 7bb766fc82ce7dd557650e4adb8d1ef6dd5e998a Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 17:36:59 +0800 Subject: [PATCH 27/56] fix(python-sdk): make runtime readiness explicit --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 10 ++-- packages/fs/tool-fs-search/README.i18n.yaml | 2 +- packages/fs/tool-fs-search/README.zh.md | 4 +- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 +- packages/sdk/server/README.zh.md | 2 +- packages/sdk/server/src/index.ts | 6 ++ .../sdk/server/tests/plugin-apply.spec.ts | 60 ++++++++++++++++++- python/sdk-runtime/README.i18n.yaml | 2 +- python/sdk-runtime/README.zh.md | 6 +- scripts/smoke-python-runtime.py | 20 ++----- scripts/verify-runtime-closure.spec.ts | 17 ++++++ scripts/verify-runtime-closure.ts | 8 ++- 15 files changed, 113 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 627ce281a3..21c607072c 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: ecc8e1e03f5660e6cf57be05909c987bfbba750d -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3b53fb19da6668a01ac0e6b2cc0570155fb5f7e1 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 54030fa4b0742fbc282bc327b0ca22747e6a20bd diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index ecc8e1e03f..40433d99e5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. @@ -64,7 +64,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, discovers its tool, and completes one model-requested call. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 3b53fb19da..54030fa4b0 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,21 +36,21 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都显式列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` sidecar;pkg 运行时通过 `process.pkg` 选择该 sidecar,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` sidecar 和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` sidecar,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、sidecar 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-sdk-jsonrpc-server` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 @@ -64,7 +64,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread` ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` sidecar 调用 `glob` 与 `grep`。MCP 场景启动临时外部 stdio server,发现其工具,并完成一次由模型请求的调用。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 4da9a9328d..29133d7e55 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md README.md: 84a3adc31f9c1580b90c038b0902e88b050f0340 -README.zh.md: 9f815e685eae93b7c184e529692c0c73fc8ba4bf +README.zh.md: 06ee6c7c1c1296c7c23b2c65cf244c1449def313 diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 9f815e685e..06ee6c7c1c 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**面向模型的文件系统发现工具**(`glob`、`grep`)由打包的 ripgrep 二进制支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。普通 Node 部署从 `@vscode/ripgrep` 解析平台二进制;pkg 单文件运行时解析与可执行程序共置的 `-rg` sidecar,sidecar 缺失时回退到依赖中的二进制。两种载体均打包 ripgrep,因此注册是无条件的,没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 解析出的二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此不涉及 shell 引号处理),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 +**面向模型的文件系统发现工具**(`glob`、`grep`)由打包的 ripgrep 二进制支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。普通 Node 部署从 `@vscode/ripgrep` 解析平台二进制;pkg 单文件运行时解析与可执行程序共置的 `-rg` 伴随文件,伴随文件缺失时回退到依赖中的二进制。两种载体均打包 ripgrep,因此注册是无条件的,没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 解析出的二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此不涉及 shell 引号处理),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -129,6 +129,6 @@ glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `g ## 已知限制与暂缓事项 - **搜索与文件访问没有共享工作区证明**——只有当工作目录与文件系统根目录指向同一工作区时,返回路径才可继续读取;本包不执行运行时跨服务校验。 -- **打包二进制固定在依赖版本上**——Node 部署使用 `@vscode/ripgrep` 选择的版本;Python 单文件运行时将对应目标的原生版本复制为必需的 `-rg` sidecar。不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败,Python 运行时包则会在启动前拒绝缺失 sidecar 的安装。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 +- **打包二进制固定在依赖版本上**——Node 部署使用 `@vscode/ripgrep` 选择的版本;Python 单文件运行时将对应目标的原生版本复制为必需的 `-rg` 伴随文件。不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败,Python 运行时包则会在启动前拒绝缺少伴随文件的安装。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 - **schema 只暴露一个有界页面**——偏移分页、大小写开关、替代输出模式与提供方支撑的发现仍不在本包范围内;达到上限的完整输出需要 spill 后端。 - **启用采样时仅按搜索根正下方的第一段路径分组**——超过上限的 `glob` 页面在这些顶层条目之间平衡,因此集中在更深处的结果(一棵均匀树里某个繁忙目录)在该层级之下仍会呈现不均;递归平衡被延期。 diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 98523811ba..4f277446d3 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/server/README.md -README.md: 5377b4fcf425cc5e10497e9d40fdddc075d52a10 -README.zh.md: dcca65a7175bb2774460bf265d98e41439df6a01 +README.md: 29ad5840b9d70c9c22ecd387730ba21ce89cbe07 +README.zh.md: f5afb255c83fa62fa4c2891cf725ffbf4c93e77d diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 5377b4fcf4..29ad5840b9 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -22,7 +22,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`. +`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`. ## Model Experience diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index dcca65a717..f5afb255c8 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 协议说明 -`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。 +`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。 ## 模型体验 diff --git a/packages/sdk/server/src/index.ts b/packages/sdk/server/src/index.ts index 20cb892b53..4cad576894 100644 --- a/packages/sdk/server/src/index.ts +++ b/packages/sdk/server/src/index.ts @@ -74,6 +74,12 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { } transport.onRequest(async (method, params) => { + // `initialize` is the SDK's readiness boundary. This plugin can activate + // before async sibling Loader entries (for example an MCP client's initial + // tool discovery), so do not advertise a ready runtime until the complete + // current tree has settled. A hand-built context without Loader remains + // immediately usable. + if (method === 'initialize') await ctx.get('loader')?.await() const result = await server.handleRequest(method, params) if (method === 'shutdown') { // Run after the handler result is written; the task then flushes, disposes, and exits. diff --git a/packages/sdk/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts index 320a61ac63..e2c86dbc61 100644 --- a/packages/sdk/server/tests/plugin-apply.spec.ts +++ b/packages/sdk/server/tests/plugin-apply.spec.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { PassThrough, Writable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as jsonrpc from '../src/index.ts' @@ -57,12 +58,17 @@ async function settle(): Promise { /** Mount the real plugin on a minimal harness with in-memory stdio and exit. */ async function mountPlugin( storageDir: string, - options: { writeDelayMs?: number; failFlush?: boolean } = {}, + options: { + writeDelayMs?: number + failFlush?: boolean + beforeServer?: (ctx: Context) => Promise | void + } = {}, ): Promise { const ctx = new Context() await ctx.plugin(agentCore, { workspaceContext: false }) await ctx.plugin(JsonlSessionPersistence, { root: storageDir }) await new Promise(resolve => setTimeout(resolve, 50)) + await options.beforeServer?.(ctx) const input = new PassThrough() const events: WireEvent[] = [] @@ -170,6 +176,58 @@ describe('dsh-sdk-jsonrpc-server plugin apply', () => { } }) + it('does not answer initialize until async sibling Loader entries settle', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-readiness-')) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + let markStarted!: () => void + let release!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + const ready = new Promise((resolve) => { release = resolve }) + let delayedEntry: Promise | undefined + const harness = await mountPlugin(storageDir, { + beforeServer: async (ctx) => { + await ctx.plugin(Loader) + ctx.loader.builtins['delayed-readiness'] = { + async apply() { + markStarted() + await ready + }, + } + delayedEntry = ctx.loader.create({ name: 'cordis:delayed-readiness' }) + await started + }, + }) + try { + const initialize = { + jsonrpc: '2.0', + id: 'init-delayed', + method: 'initialize', + params: { cwd: storageDir, provider: 'deepseek-official', model: 'apply-model' }, + } + const probe = { jsonrpc: '2.0', id: 'probe-during-delay', method: 'nope/unknown' } + harness.sendRaw(`${JSON.stringify(initialize)}\n${JSON.stringify(probe)}\n`) + + // The transport processes independent requests concurrently. Receiving + // this later probe proves the preceding initialize handler has reached + // its Loader wait, without relying on a scheduler delay. + await harness.waitForFrame(frame => frame.id === 'probe-during-delay', 'probe while initialize waits') + expect(harness.frames().some(frame => frame.id === 'init-delayed')).toBe(false) + + release() + await delayedEntry + const response = await harness.waitForFrame(frame => frame.id === 'init-delayed', 'initialize response after Loader settlement') + expect(response).toMatchObject({ + id: 'init-delayed', + result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } }, + }) + } finally { + release() + await Promise.allSettled(delayedEntry === undefined ? [] : [delayedEntry]) + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('drives a session/prompt turn end-to-end and forwards session notifications as output frames', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-prompt-')) const llmServer = await mockCompletionServer() diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 96739c3baa..b8eacd05a3 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md README.md: 597d69a803a7cd1204fd48456f8e1ba18d9786e5 -README.zh.md: 888ecdad437d001b84b1af71c04e2da016d3901e +README.zh.md: 30dc6b2a34709ac76d16133be34f3d0c87f93b8a diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 888ecdad43..30dc6b2a34 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),以及匹配目标平台的 ripgrep `-rg` sidecar。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),以及匹配目标平台的 ripgrep `-rg` 伴随文件。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 @@ -17,12 +17,12 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件及其匹配的 ripgrep `-rg` sidecar。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少任一 sidecar 都意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用文件系统搜索或 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在或存在多个运行时可执行文件、缺失或多余的 sidecar、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的 ripgrep `-rg` 伴随文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少任一伴随文件都意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用文件系统搜索或 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在或存在多个运行时可执行文件、缺失或多余的伴随文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体);它会在所有平台校验必要的 `-rg` sidecar,并在 macOS 上额外校验 `-spawn-helper` sidecar。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体);它会在所有平台校验必要的 `-rg` 伴随文件,并在 macOS 上额外校验 `-spawn-helper` 伴随文件。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index b478efe5e0..f001adccb2 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -149,6 +149,7 @@ MCP_SERVER_SCRIPT = """\ import json import os import sys +import time log_path = os.environ.get("MCP_SMOKE_LOG") @@ -179,6 +180,10 @@ for line in sys.stdin: }, }) elif method == "tools/list": + # Keep discovery pending longer than the old smoke's 100 ms grace + # period. An SDK runtime that answers initialize too early will make + # its first model request without this tool and fail deterministically. + time.sleep(0.25) send({ "jsonrpc": "2.0", "id": request_id, @@ -255,20 +260,6 @@ def mcp_cordis(server_script: Path) -> str: ], indent=2) -def wait_for_mcp_discovery(log_path: Path) -> None: - """Wait until the external server has answered initial tool discovery.""" - deadline = time.monotonic() + 10 - while time.monotonic() < deadline: - if log_path.exists() and "tools/list" in log_path.read_text().splitlines(): - # The server records the request before flushing its response; give the - # client one scheduler interval to register the returned generation. - time.sleep(0.1) - return - time.sleep(0.025) - observed = log_path.read_text() if log_path.exists() else "" - raise AssertionError(f"packaged MCP client did not complete tool discovery: {observed}") - - class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -861,7 +852,6 @@ def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None: base_url=base_url, request_timeout_seconds=60, ) as harness: - wait_for_mcp_discovery(discovery_log) result = harness.run(MCP_PROMPT, session_id="mcp-smoke") assert result.final_response == MCP_TEXT, result.final_response diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index 50d97e0dae..09a1b044ab 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -99,6 +99,23 @@ describe('verifyRuntimeClosure', () => { expect(result.failures).toEqual([]) }) + it('requires preset plugins to be linked from the workspace', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': '1.2.3' } }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` +- id: plugin + name: '@scope/plugin' +`, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.failures).toEqual([ + 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64)', + ]) + }) + it('fails when no shipped preset is discovered', async () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index 549c06e9ad..927bae8db5 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -133,9 +133,13 @@ async function missingPresetPlugins( for (const target of targets) { const processPlatform = processPlatformForTarget(target) for (const plugin of activeBarePluginPackages(document, processPlatform)) { - if (runtimeDependencies[plugin] !== undefined) continue + const version = runtimeDependencies[plugin] + if (version?.startsWith('workspace:') === true) continue const preset = basename(dirname(presetPath)) - const key = `${preset} preset -> ${plugin}` + const declaration = version === undefined + ? '' + : ` [runtime dependency is ${JSON.stringify(version)}; expected workspace:]` + const key = `${preset} preset -> ${plugin}${declaration}` const targets = missing.get(key) ?? new Set() targets.add(target) missing.set(key, targets) From aab839a971fcfd37dfff687f58ea13dbc79f3c2f Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 17:39:38 +0800 Subject: [PATCH 28/56] feat(web): Implement file-open failure handling in chat view --- ...08-18-tool-row-file-open-failure.i18n.yaml | 6 ++ .../2026-08-18-tool-row-file-open-failure.md | 33 +++++++ ...026-08-18-tool-row-file-open-failure.zh.md | 33 +++++++ ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 4 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 4 +- apps/web/tests/navigation-panes.e2e.ts | 15 ++- apps/web/tests/seeded-history.e2e.ts | 45 ++++++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 5 +- .../src/client/chat/ChatView.module.css | 12 +++ .../src/client/chat/ChatView.tsx | 80 +++++++++++++++- .../src/client/contract/slots.ts | 5 +- .../ui-conversation/src/client/locales.ts | 4 + .../tests/apply-inject.client.spec.tsx | 8 ++ .../tests/chat-view.client.spec.tsx | 95 ++++++++++++++++++- 18 files changed, 333 insertions(+), 28 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml new file mode 100644 index 0000000000..0e7aafe5bc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md +2026-08-18-tool-row-file-open-failure.md: 8b54fe323f1ea9dba302a9cb8cbf10944ac7ea3f +2026-08-18-tool-row-file-open-failure.zh.md: a969905729b6cccb510ca85bcc698ddbc33ccb54 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md new file mode 100644 index 0000000000..8b54fe323f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md @@ -0,0 +1,33 @@ +# Agent Note: Tool-row file-open failures stay visible + +Status: implemented + +English | [中文](2026-08-18-tool-row-file-open-failure.zh.md) + +## Problem + +Tool-row path clicks already call `host.openPath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try. + +The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.md) still owns the link gesture and the Host handoff. This note owns only the refusal. + +## Decision + +The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or `fileOpen.unknown` when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog. + +The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. + +The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix. + +## Alternatives considered + +- **Per-row inline error.** The Host call is conversation-owned and several entries share one opener; a row-local banner would duplicate the same refusal next to every click target. +- **Toast without retry.** The product ask is the reason *and* a retry entry. The workspace folder-adoption dialog already pairs those two. +- **Chat-store persistence.** A failed open is transient view state. The chat store survives reload, so a leftover dialog would return after a refresh that cannot usefully retry the original gesture. + +## Consequences + +A silent Host refusal is no longer a success from the reader's seat. Headless or remote deployments that click a path now see why the desktop handoff did not happen. The view holds one extra request-generation counter so dismiss and retry stay race-safe. + +## Testing + +Package specs cover inject rejection, the dialog copy (Error, non-Error, empty), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row and asserts the English reason plus a second call with the same payload. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md new file mode 100644 index 0000000000..a969905729 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Tool-row file-open failures stay visible + +Status: implemented + +[English](2026-08-18-tool-row-file-open-failure.md) | 中文 + +## 问题 + +工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `host.openPath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。 + +[用系统应用打开文件的决策](../feature/2026-07-28-tool-call-file-open-in-os.md) 仍然拥有链接手势和 Host 交接。本 Agent Note 只拥有拒绝路径。 + +## 决策 + +inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用 `fileOpen.unknown`),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。 + +对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。 + +Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 + +## 考虑过的替代方案 + +- **按行内联错误。** Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。 +- **没有重试的 toast。** 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。 +- **写入 chat store 并持久化。** 打开失败是瞬时视图状态。chat store 会在刷新后存活,于是残留对话框会在无法有效重试原手势的刷新之后回来。 + +## 后果 + +从读者一侧看,静默的 Host 拒绝不再等同于成功。无头或远程部署点击路径时,能看到桌面交接为何没有发生。视图多持有一个请求世代计数器,使关闭与重试在竞态下仍然安全。 + +## 测试 + +包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,并断言英文原因以及对同一 payload 的第二次调用。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 5e378099c9..393f7c17b9 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: d78cf4da2c1861a66a0cefb24dba785c7cb1279d -2026-07-28-tool-call-file-open-in-os.zh.md: c13f6e60c0c70a3036f50c678c7b348c91f874c3 +2026-07-28-tool-call-file-open-in-os.md: 08fc51cc3a5d2fb43b67dc158fd1ee789fafdb68 +2026-07-28-tool-call-file-open-in-os.zh.md: 59079533b502d234bafb0b53c123e5895e105833 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index d78cf4da2c..08fc51cc3a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -23,9 +23,9 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil ## Consequences -Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. +Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). ## Risks -- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error. +- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat view shows that Host error and offers retry. - Relative paths without a session cwd are forwarded verbatim and may fail on the host. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index c13f6e60c0..59079533b5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -23,9 +23,9 @@ Status: implemented ## 后果 -点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。 +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.md))。 ## 风险 -- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 +- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天视图展示该 Host 错误并提供重试。 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index a0b34e53b9..1e2a0953b2 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' import { strFromU8, unzipSync } from 'fflate' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -415,8 +415,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { ok: true, value: { opened: true as const } }, + })) + try { + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + } finally { + openPath.mockRestore() + } }, 60_000) it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => { diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 20ad261c0d..dd3ddf1308 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -14,7 +14,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' @@ -396,12 +396,51 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.waitFor({ timeout: 10_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { ok: true, value: { opened: true as const } }, + })) + try { + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + } finally { + openPath.mockRestore() + } // Path label survives from the recorded args (a.txt). await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) + it.skipIf(MODE === 'record')('a Host open refusal keeps the reason and retries the same path', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-file-open-failure')) + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { + ok: false as const, + error: { code: 'internal', message: 'xdg-open is not available', details: {} }, + }, + })) + try { + await fileLink.click() + const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) + await dialog.waitFor({ timeout: 5_000 }) + await expect.poll(() => dialog.getByRole('alert').innerText(), { timeout: 5_000 }) + .toBe('path open failed: xdg-open is not available') + await page.getByRole('button', { name: 'Retry' }).click() + await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) + expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) + await page.getByRole('button', { name: 'Cancel' }).click() + await expect.poll(() => page.getByRole('dialog', { name: 'Couldn’t open file' }).count(), { + timeout: 5_000, + }).toBe(0) + } finally { + openPath.mockRestore() + } + }) + it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction')) const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6d866e05e7..454e0fac85 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: d1a265b5789d9f1d9b5e630e0548ae5f619eebbf -README.zh.md: 3f303391d39bc040b4a6a5a2d1f6a34fe8891919 +README.md: 6dd51df7a5c76ba8085f3a7816fc91927364eef6 +README.zh.md: 7830ae193d7f3e2dcf0f681ea7714cc51eb86299 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d1a265b578..6dd51df7a5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -22,7 +22,7 @@ Logged non-user messages render as a default-collapsed disclosure whose header n A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). -The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. +The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. A path click through the injected `openFile` asks the Host to open that path (relative paths resolve against the session cwd). A Host or OS refusal opens an in-page dialog with the thrown reason and a Retry of the same path; Cancel, Escape, the close control, and a mask click dismiss it ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md)). The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3f303391d3..7830ae193d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -20,7 +20,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 -聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。 +聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。经注入的 `openFile` 点击路径会请 Host 打开该路径(相对路径按会话 cwd 解析)。Host 或操作系统拒绝时,页面内对话框展示抛出的原因,并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框([决策](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md))。 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index f57caea9e5..1397570bf3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -394,10 +394,7 @@ export function apply(ctx: Context): void { fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner), openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd - void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => { - // Host/OS open failures stay silent in the chat row; the native - // app surfaces its own error dialog when the path is unusable. - }) + return workspaces.openPath(resolveWorkspacePath(cwd, path)) }, loadOlder: () => { void scoped.loadOlder() }, loadImage: attachment => conversation.resolveImage(sessionId, attachment), diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index d16608c856..32f9f38da7 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -190,3 +190,15 @@ .toBottom:hover { background: var(--dsw-alias-button-floating-hover); } + +/* Host open-path refusal: same dialog family as the workspace folder error. */ +.modalAction { + min-width: 72px; +} + +.modalError { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 58e63b312f..fd1e466ca1 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,7 +1,8 @@ // ChatView: the default conversation view — one stable keyed parent list over // final business Nodes, plus paging, pending steering and bottom-follow. // Each row dispatches through 'conversation.chat.node'; ui-tool owns the -// tool-call renderer and its recursive root/subcall composition. +// tool-call renderer and its recursive root/subcall composition. A Host +// open-path refusal from the injected opener is an in-page dialog here. // // Scroll: when nested under `[data-conversation-scroll]` (active conversation // column), that host is the scrollport and this view is flow content; when @@ -12,9 +13,9 @@ // ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool // lifecycle updates replace only their own row without remounting it. -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' @@ -95,6 +96,12 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP } } +/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */ +function openFailureMessage(error: unknown, fallback: string): string { + const message = error instanceof Error ? error.message : String(error) + return message === '' ? fallback : message +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -159,6 +166,34 @@ export function ChatView({ const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) + const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) + const [fileOpenBusy, setFileOpenBusy] = useState(false) + // Close/retry must ignore a settlement that started before the latest + // gesture; otherwise a cancelled in-flight refusal reopens the dialog. + const fileOpenRequest = useRef(0) + + const requestOpenFile = useCallback((path: string) => { + const id = ++fileOpenRequest.current + setFileOpenBusy(true) + void Promise.resolve(openFile(path)).then( + () => { + if (id !== fileOpenRequest.current) return + setFileOpenError(null) + setFileOpenBusy(false) + }, + (error: unknown) => { + if (id !== fileOpenRequest.current) return + setFileOpenError({ path, message: openFailureMessage(error, t('fileOpen.unknown')) }) + setFileOpenBusy(false) + }, + ) + }, [openFile, t]) + + const closeFileOpenError = useCallback(() => { + fileOpenRequest.current += 1 + setFileOpenError(null) + setFileOpenBusy(false) + }, []) const pendingSteering = useMemo( () => inbox.filter(item => item.placement === 'steering'), @@ -386,7 +421,7 @@ export function ChatView({ useSession={useSession} selectedCallId={selectedCallId} cwd={cwd} - openFile={openFile} + openFile={requestOpenFile} inspectCall={inspectCall} forkAt={forkAt} loadImage={loadImage} @@ -422,6 +457,43 @@ export function ChatView({
)} + {fileOpenError !== null && ( + { requestOpenFile(fileOpenError.path) }} + t={t} + /> + )} ) } + +/** In-page Host open-path refusal: the wire reason plus a retry of the same path. */ +function FileOpenErrorDialog({ + message, busy, onClose, onRetry, t, +}: { + message: string + busy: boolean + onClose: () => void + onRetry: () => void + t: ChatViewSlotProps['t'] +}) { + return ( + + + + + )} + > +
{message}
+
+ ) +} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 21259146a7..4e4522052a 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -677,9 +677,10 @@ export interface ChatViewInjected { openDetails: (target: SelectionTarget) => void /** * Open a tool-arg filesystem path with the host OS default application - * (relative paths resolve against the session cwd). + * (relative paths resolve against the session cwd). Rejects when the Host + * cannot hand the path off; the chat view shows that reason and a retry. */ - openFile: (path: string) => void + openFile: (path: string) => void | Promise loadOlder: () => void /** Resolve a session-authorized historical image for inline display. */ loadImage: (attachment: ImageAttachmentRef) => Promise diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c9b6f658ca..a441ac044c 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -91,6 +91,8 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', + 'fileOpen.title': '无法打开文件', + 'fileOpen.unknown': '无法打开此文件', 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', @@ -260,6 +262,8 @@ export const en = { 'chat.loadError': 'Failed to load history: {message} ({code})', 'chat.loadOlder': 'Load earlier', 'chat.toBottom': 'Back to bottom', + 'fileOpen.title': 'Couldn’t open file', + 'fileOpen.unknown': 'Couldn’t open this file', 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 9cec01789a..38801e486a 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -240,6 +240,14 @@ describe('conversation slot inject API', () => { await b.runtime.dispose() }) + it('openFile rejects when the Host cannot open the path', async () => { + const b = await bench() + b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available'))) + const { injected } = b.chatViewApi(ROOT) + await expect(Promise.resolve(injected.openFile('src/a.ts'))).rejects.toThrow('xdg-open is not available') + await b.runtime.dispose() + }) + it('routes workspace switching through the runtime owner, carrying the draft', async () => { const b = await bench() const resident = b.residentApi(ROOT) diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 360b589bdc..6ca09a997e 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -4,7 +4,7 @@ // ObservableSnapshot fake, no wire or Tool presentation plugin. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useEffect } from 'react' import type { AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, @@ -967,10 +967,101 @@ describe('ChatView', () => { }) const owner = calls[0]?.owner as RoutedChatNodeOwner expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block) - expect(owner.openFile).toBe(h.openFile) + expect(owner.openFile).not.toBe(h.openFile) + owner.openFile('src/a.ts') + expect(h.openFile).toHaveBeenCalledWith('src/a.ts') expect(owner.inspectCall).toBe(h.inspectCall) }) + it('shows a Host open refusal with the reason and retries the same path', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('xdg-open is not available')) + .mockResolvedValueOnce(undefined) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件' })).toBeTruthy() + }) + expect(screen.getByRole('alert').textContent).toBe('xdg-open is not available') + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull() + }) + expect(openFile).toHaveBeenCalledTimes(2) + expect(openFile).toHaveBeenNthCalledWith(1, 'src/a.ts') + expect(openFile).toHaveBeenNthCalledWith(2, 'src/a.ts') + }) + + it('keeps a non-Error Host refusal visible and dismisses it on cancel', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce('permission denied') + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('notes.md') }) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('permission denied') + }) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(openFile).toHaveBeenCalledTimes(1) + }) + + it('substitutes the unknown-open copy when the Host refusal has no text', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('')) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('empty.ts') }) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('无法打开此文件') + }) + }) + + it('ignores a Host refusal that settles after the dialog is dismissed', async () => { + let rejectRetry!: (error: unknown) => void + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('first refusal')) + .mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectRetry = reject + })) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('first refusal') + }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(screen.queryByRole('dialog')).toBeNull() + await act(async () => { rejectRetry(new Error('late refusal')) }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('ignores a Host open that succeeds after the dialog is dismissed', async () => { + let resolveRetry!: () => void + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('first refusal')) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveRetry = () => { resolve() } + })) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('first refusal') + }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + await act(async () => { resolveRetry() }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true }) const view = render() From 1b9f9ae256e469eea3aa8ae1a0fb95a6b426a373 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 18:02:02 +0800 Subject: [PATCH 29/56] fix(ci): install local Python release wheels --- .github/workflows/build-exe-for-python-sdk.yml | 16 +++++++++------- .github/workflows/python-release.yml | 8 ++++++-- python/development.i18n.yaml | 4 ++-- python/development.md | 12 ++++++++++-- python/development.zh.md | 12 ++++++++++-- scripts/ci-workflow.spec.ts | 14 ++++++++++++-- 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 5fc6de459d..02c04257b0 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -261,15 +261,16 @@ jobs: name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python - - name: Install only the SDK into a clean venv and run zero-config + - name: Install local SDK and runtime wheels into a clean venv env: - VERSION: ${{ needs.plan.outputs.version }} + RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} + SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ - --find-links dist-python \ - deepseek-harness-sdk=="$VERSION" + "dist-python/$SDK_WHEEL" \ + "dist-python/$RUNTIME_WHEEL" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ @@ -299,7 +300,8 @@ jobs: if: runner.os == 'Linux' env: RUNNER_ARCH: ${{ runner.arch }} - VERSION: ${{ needs.plan.outputs.version }} + RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} + SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail case "$RUNNER_ARCH" in @@ -307,9 +309,9 @@ jobs: ARM64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; esac - docker run --rm -e VERSION -e DSH_TELEMETRY_DISABLED -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' + docker run --rm -e RUNTIME_WHEEL -e SDK_WHEEL -e DSH_TELEMETRY_DISABLED -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk - /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" + /tmp/dsh-sdk/bin/python -m pip install "/work/dist-python/$SDK_WHEEL" "/work/dist-python/$RUNTIME_WHEEL" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp ' diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index a395a8e8bd..f5b9c63c4b 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -68,9 +68,13 @@ jobs: print(f"version={release['pep440_version'](repository_version)}") PY - - name: Install and run the published entry path + - name: Install local release wheels and run the public entry path + env: + VERSION: ${{ steps.compatibility-version.outputs.version }} run: | - python -m pip install --find-links dist "deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}" + python -m pip install \ + "dist/deepseek_harness_sdk-$VERSION-py3-none-any.whl" \ + "dist/deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl" python scripts/smoke-python-runtime.py --scenario sdk-default python scripts/smoke-python-runtime.py --scenario sdk-mcp diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 72df1143fc..a9cdaa8daa 100644 --- a/python/development.i18n.yaml +++ b/python/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/development.md -development.md: fe62a109f2643afe0b9be1ed51b86be0b9fa731f -development.zh.md: d4ab9850d6c83dc17a740240f97cef89d61faaa0 +development.md: 617d030294dafa51aea513adb811bb5f377431c9 +development.zh.md: 2049ad12856c4107788aec168e3724965cebd5af diff --git a/python/development.md b/python/development.md index fe62a109f2..617d030294 100644 --- a/python/development.md +++ b/python/development.md @@ -61,10 +61,18 @@ The root `package.json` version is authoritative for both Python distributions. Build the pure SDK wheel once and one runtime wheel on each native platform: ```sh -version="$(node -p "require('./package.json').version")" +version="$(python - <<'PY' +import runpy + +release = runpy.run_path("scripts/build-python-release.py") +print(release["pep440_version"](release["repository_version"]())) +PY +)" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness-sdk=="$version" +pip install \ + "dist-python/deepseek_harness_sdk-$version-py3-none-any.whl" \ + "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS 14 or newer on arm64. A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. diff --git a/python/development.zh.md b/python/development.zh.md index d4ab9850d6..2049ad1285 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -61,10 +61,18 @@ with DeepSeekHarness() as harness: 纯 SDK wheel 包只需构建一次;每个原生平台分别构建一个运行时 wheel 包: ```sh -version="$(node -p "require('./package.json').version")" +version="$(python - <<'PY' +import runpy + +release = runpy.run_path("scripts/build-python-release.py") +print(release["pep440_version"](release["repository_version"]())) +PY +)" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness-sdk=="$version" +pip install \ + "dist-python/deepseek_harness_sdk-$version-py3-none-any.whl" \ + "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` 运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS 14 或更高版本的 arm64。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 45712860e5..9c0f56c85a 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -268,7 +268,10 @@ describe('Python release workflows', () => { }, }) expect(pythonCompat.strategy).toMatchObject({ matrix: { python: ['3.10', '3.14'] } }) - expect(JSON.stringify(pythonCompat.steps)).toContain('deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}') + const pythonCompatSteps = JSON.stringify(pythonCompat.steps) + expect(pythonCompatSteps).toContain('dist/deepseek_harness_sdk-$VERSION-py3-none-any.whl') + expect(pythonCompatSteps).toContain('dist/deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl') + expect(pythonCompatSteps).not.toContain('--find-links') const validateSteps = JSON.stringify(validate.steps) const authorize = validate.steps.filter(isRecord).find(step => step.name === 'Authorize publication request') if (!isRecord(authorize) || typeof authorize.run !== 'string') { @@ -341,7 +344,14 @@ describe('Python release workflows', () => { expect(plan.if).toContain('inputs.ci') expect(plan.if).toContain('inputs.release') expect(JSON.stringify(plan.steps)).toContain('pep440_version') - expect(JSON.stringify(workflow)).toContain('macosx_14_0_arm64') + const workflowJson = JSON.stringify(workflow) + expect(workflowJson).toContain('macosx_14_0_arm64') + expect(workflowJson).toContain('dist-python/$SDK_WHEEL') + expect(workflowJson).toContain('dist-python/$RUNTIME_WHEEL') + expect(workflowJson).toContain('/work/dist-python/$SDK_WHEEL') + expect(workflowJson).toContain('/work/dist-python/$RUNTIME_WHEEL') + expect(workflowJson).not.toContain('--find-links dist-python') + expect(workflowJson).not.toContain('--find-links /work/dist-python') expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" }) expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') From a3fc0b5ecdbe02a3dda42e0b93f4965654902308 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 18:06:06 +0800 Subject: [PATCH 30/56] test(subagent): use platform Codex argv --- packages/subagent/subagent-codex/tests/subagent-codex.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index e3864d0a19..d0157acbdf 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1957,7 +1957,7 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ - argv: ['codex', 'app-server', '--stdio'], + argv: codexAppServerArgv(), env: { OPENAI_API_KEY: 'fake' }, graceMs: 25, cwd: process.cwd(), From b1be9e93cd677cddc19f0e39d71340e4f951f77c Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 18:54:18 +0800 Subject: [PATCH 31/56] fix: optimize ui --- apps/web/tests/seeded-history.e2e.ts | 3 +-- .../src/client/chat/ChatView.module.css | 7 ------- .../src/client/chat/ChatView.tsx | 21 +++++++++++++++---- .../tests/chat-view.client.spec.tsx | 10 ++++----- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index dd3ddf1308..6b4769a916 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -427,8 +427,7 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.click() const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) await dialog.waitFor({ timeout: 5_000 }) - await expect.poll(() => dialog.getByRole('alert').innerText(), { timeout: 5_000 }) - .toBe('path open failed: xdg-open is not available') + await expect(dialog.getByText('path open failed: xdg-open is not available')).toBeVisible({ timeout: 5_000 }) await page.getByRole('button', { name: 'Retry' }).click() await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 32f9f38da7..ffee12984f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -195,10 +195,3 @@ .modalAction { min-width: 72px; } - -.modalError { - margin-top: 8px; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-state-error-primary); -} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index fd1e466ca1..48646fb3be 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -102,6 +102,20 @@ function openFailureMessage(error: unknown, fallback: string): string { return message === '' ? fallback : message } +/** + * Local demo only: `?openFileFail=1` randomly refuses (or lets the Host + * open succeed) so the three dialog cases can be clicked through. + */ +function demoOpenFailure(): Promise | undefined { + if (typeof location === 'undefined') return undefined + if (!new URLSearchParams(location.search).has('openFileFail')) return undefined + const pick = Math.floor(Math.random() * 4) + if (pick === 0) return Promise.reject(new Error('xdg-open is not available')) + if (pick === 1) return Promise.reject('permission denied') + if (pick === 2) return Promise.reject(new Error('')) + return undefined +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -175,7 +189,7 @@ export function ChatView({ const requestOpenFile = useCallback((path: string) => { const id = ++fileOpenRequest.current setFileOpenBusy(true) - void Promise.resolve(openFile(path)).then( + void Promise.resolve(demoOpenFailure() ?? openFile(path)).then( () => { if (id !== fileOpenRequest.current) return setFileOpenError(null) @@ -486,14 +500,13 @@ function FileOpenErrorDialog({ onClose={onClose} closeLabel={t('close')} title={t('fileOpen.title')} + description={message} footer={( <> )} - > -
{message}
- + /> ) } diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 6ca09a997e..a12368d12a 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -984,7 +984,7 @@ describe('ChatView', () => { await waitFor(() => { expect(screen.getByRole('dialog', { name: '无法打开文件' })).toBeTruthy() }) - expect(screen.getByRole('alert').textContent).toBe('xdg-open is not available') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('xdg-open is not available') await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() @@ -1002,7 +1002,7 @@ describe('ChatView', () => { render() await act(async () => { h.toolOwners[0]!.openFile('notes.md') }) await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('permission denied') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('permission denied') }) fireEvent.click(screen.getByRole('button', { name: '取消' })) expect(screen.queryByRole('dialog')).toBeNull() @@ -1017,7 +1017,7 @@ describe('ChatView', () => { render() await act(async () => { h.toolOwners[0]!.openFile('empty.ts') }) await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('无法打开此文件') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('无法打开此文件') }) }) @@ -1033,7 +1033,7 @@ describe('ChatView', () => { render() await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('first refusal') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal') }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) fireEvent.click(screen.getByRole('button', { name: '取消' })) @@ -1054,7 +1054,7 @@ describe('ChatView', () => { render() await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('first refusal') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal') }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) fireEvent.click(screen.getByRole('button', { name: '取消' })) From 4037762dc0bbc3e671045e729868768320a5eafd Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 18:57:37 +0800 Subject: [PATCH 32/56] fix: build --- apps/web/tests/seeded-history.e2e.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 6b4769a916..ec12ddfeaf 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -427,7 +427,8 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.click() const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) await dialog.waitFor({ timeout: 5_000 }) - await expect(dialog.getByText('path open failed: xdg-open is not available')).toBeVisible({ timeout: 5_000 }) + await expect.poll(() => dialog.innerText(), { timeout: 5_000 }) + .toContain('path open failed: xdg-open is not available') await page.getByRole('button', { name: 'Retry' }).click() await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) From e3752e207ee898189d11d230ce094e85830b4dbb Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:02:38 +0800 Subject: [PATCH 33/56] fix: remove debug code --- .../ui-conversation/src/client/chat/ChatView.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 48646fb3be..e686b879f0 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -102,20 +102,6 @@ function openFailureMessage(error: unknown, fallback: string): string { return message === '' ? fallback : message } -/** - * Local demo only: `?openFileFail=1` randomly refuses (or lets the Host - * open succeed) so the three dialog cases can be clicked through. - */ -function demoOpenFailure(): Promise | undefined { - if (typeof location === 'undefined') return undefined - if (!new URLSearchParams(location.search).has('openFileFail')) return undefined - const pick = Math.floor(Math.random() * 4) - if (pick === 0) return Promise.reject(new Error('xdg-open is not available')) - if (pick === 1) return Promise.reject('permission denied') - if (pick === 2) return Promise.reject(new Error('')) - return undefined -} - function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -189,7 +175,7 @@ export function ChatView({ const requestOpenFile = useCallback((path: string) => { const id = ++fileOpenRequest.current setFileOpenBusy(true) - void Promise.resolve(demoOpenFailure() ?? openFile(path)).then( + void Promise.resolve(openFile(path)).then( () => { if (id !== fileOpenRequest.current) return setFileOpenError(null) From 6ebf8d199d35bcbfa7a1648ac6ceb7123e4c28c3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 19:14:02 +0800 Subject: [PATCH 34/56] fix(subagent): preserve Codex process exit facts --- packages/subagent/subagent-codex/src/run.ts | 90 +++++----- packages/subagent/subagent-codex/src/wire.ts | 19 +- .../subagent-codex/tests/real-product.spec.ts | 44 ++++- .../tests/subagent-codex.spec.ts | 167 +++++++++--------- 4 files changed, 178 insertions(+), 142 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 6265587fab..61cfee871e 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -164,13 +164,7 @@ export async function disposeCodexChild( wire: CodexAppServerWire, child: SubprocessHandle, ): Promise { - const failures: Error[] = [] - let outcome: SubprocessOutcome | undefined - try { - wire.close() - } catch (error: unknown) { - failures.push(thrown(error)) - } + wire.close() if (child.pid > 0) { try { @@ -182,31 +176,17 @@ export async function disposeCodexChild( try { await child.waitForExit() } catch (error: unknown) { - failures.push(thrown(error)) - } - try { - outcome = await child.done - } catch (error: unknown) { - failures.push(thrown(error)) + const outcome = await child.done + throw new CodexRunFailure({ + stage: 'teardown', + category: 'unknown', + outcome, + }, thrown(error)) } + await child.done } else { await child.done.catch(() => {}) } - - const firstFailure = failures[0] - if (firstFailure === undefined) return - const facts = { - stage: 'teardown', - category: 'unknown', - outcome, - } as const - if (failures.length === 1) { - throw new CodexRunFailure(facts, firstFailure) - } - throw new AggregateError( - failures.map(failure => new CodexRunFailure(facts, failure)), - `subagent-codex: ${failureDiagnostic(facts)}`, - ) } /** @@ -271,16 +251,23 @@ export async function startCodexRun( } } - const processFailure: Promise = child.done.then( - outcome => Promise.reject(new CodexRunFailure({ - stage: 'process', - category: 'process-exit', - outcome, - })), - (error: unknown) => Promise.reject(new CodexRunFailure({ - stage: 'process', - category: 'unknown', - }, thrown(error))), + let processFailureFacts: CodexFailureFacts | undefined + const processFailure: Promise = child.done.then( + (outcome) => { + processFailureFacts = { + stage: 'process', + category: 'process-exit', + outcome, + } + throw new CodexRunFailure(processFailureFacts) + }, + (error: unknown) => { + processFailureFacts = { + stage: 'process', + category: 'unknown', + } + throw new CodexRunFailure(processFailureFacts, thrown(error)) + }, ) // A normal post-result dispose also closes the process. Keep that expected // late rejection observed after the result race has already settled. @@ -349,19 +336,32 @@ export async function startCodexRun( processFailure, ]) if (terminal.stopReason === 'completed') return terminal - const facts = wire.collectFailure() ?? { - stage: 'turn', - category: 'unknown', - } + const facts = wire.collectFailure() return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } } catch (error: unknown) { // Give stderr data already queued in Node one turn to reach the wire - // before settlement snapshots the diagnostic; later OS data is best-effort. + // before settlement snapshots the diagnostic. await new Promise((resolve) => { setImmediate(resolve) }) - const wireFacts = wire.collectFailure() + const endedBeforeTerminal = wire.endedBeforeTerminal() + if ( + endedBeforeTerminal + && processFailureFacts === undefined + && !runAbort.signal.aborted + ) { + try { + const exited = await child.waitForExit( + AbortSignal.timeout(spec.disposeGraceMs), + ) + if (exited) await child.done + } catch { + // The wire failure remains authoritative when exit observation fails. + } + } const facts = error instanceof CodexRunFailure ? error.facts - : wireFacts ?? { stage: 'turn', category: 'unknown' } + : endedBeforeTerminal && processFailureFacts !== undefined + ? processFailureFacts + : wire.collectFailure() recordFailureDiagnostic(facts) throw error instanceof CodexRunFailure ? error diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 9cd6b4f049..cbc0504e08 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -237,6 +237,8 @@ export class CodexAppServerWire { readonly reason: string } | undefined private stderrTail = '' + private inputEnded = false + private terminalObserved = false private closed = false constructor( @@ -270,6 +272,14 @@ export class CodexAppServerWire { this.transport.start() } + /** + * Whether protocol output ended before a terminal turn notification. + * @returns `true` only for an early protocol close without a terminal turn. + */ + endedBeforeTerminal(): boolean { + return this.inputEnded && !this.terminalObserved + } + /** * Perform the required app-server initialize/initialized handshake. * @param signal - unpublished-start cancellation. @@ -415,10 +425,11 @@ export class CodexAppServerWire { /** * The structured failure fact observed for this published turn. - * @returns a fixed stage/category pair and optional HTTP status. + * Call only after a non-completed return or rejection from {@link runTurn}. + * @returns the fixed stage/category pair and optional HTTP status. */ - collectFailure(): CodexWireFailureFacts | undefined { - return this.failure + collectFailure(): CodexWireFailureFacts { + return this.failure as CodexWireFailureFacts } /** @@ -469,6 +480,7 @@ export class CodexAppServerWire { } private readonly onInputEnd = (): void => { + this.inputEnded = true this.fail(new Error('subagent-codex: app-server protocol stream closed')) } @@ -720,6 +732,7 @@ export class CodexAppServerWire { return } if (id !== this.turnId) return + this.terminalObserved = true if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) } diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 6b5bdb6793..bd48478336 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -15,7 +15,10 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import type { + SubprocessHandle, + SubprocessOutcome, +} from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import type { CodexPermissionMode } from '../src/run.ts' @@ -132,6 +135,26 @@ async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise + readonly required?: string[] + readonly type?: string | string[] +} + function responseInputTexts(body: Record): string[] { if (!Array.isArray(body.input)) return [] return body.input.flatMap((item): string[] => { @@ -175,10 +198,7 @@ describe('real @openai/codex 0.147.0 product', () => { )) as { definitions: { CodexErrorInfo: { - oneOf: Array<{ - enum?: string[] - properties?: Record - }> + oneOf: JsonSchemaNode[] } } } @@ -203,6 +223,16 @@ describe('real @openai/codex 0.147.0 product', () => { 'responseTooManyFailedAttempts', 'activeTurnNotSteerable', ]) + for (const variant of schema.definitions.CodexErrorInfo.oneOf.slice(1, 5)) { + const category = Object.keys(variant.properties ?? {})[0]! + const detail = variant.properties?.[category] + expect(detail?.required).toBeUndefined() + expect(detail?.properties?.httpStatusCode).toEqual({ + format: 'uint16', + minimum: 0, + type: ['integer', 'null'], + }) + } const run = await harness.ctx.subagents.start('codex', { prompt: [{ type: 'text', text: task }], @@ -323,10 +353,10 @@ describe('real @openai/codex 0.147.0 product', () => { await fixture.requestStarted expect(harness.handles).toHaveLength(1) harness.handles[0]!.terminate() - await harness.handles[0]!.done + const outcome = await harness.handles[0]!.done await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: unknown)', + diagnostic: expectedProcessExitDiagnostic(outcome), stopReason: 'error', }) await run.dispose() diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index d0157acbdf..cc46215cca 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1479,50 +1479,6 @@ describe('run lifecycle and quiescence', () => { } }) - it('uses safe unknown fallbacks when the wire supplies no failure fact', async () => { - { - const collectFailure = vi.spyOn( - CodexAppServerWire.prototype, - 'collectFailure', - ).mockReturnValue(undefined) - const { child, run, turnStart } = await publishRun() - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { - codexErrorInfo: 'contextWindowExceeded', - })) - await expect(run.result).resolves.toEqual({ - output: [], - diagnostic: expectedFailureDiagnostic('turn', 'unknown'), - stopReason: 'max-tokens', - }) - collectFailure.mockRestore() - await run.dispose() - } - { - const runTurn = vi.spyOn(CodexAppServerWire.prototype, 'runTurn') - .mockRejectedValueOnce(new Error('SECRET_TOKEN wire failure')) - const child = fakeChild() - const starting = startCodexRun(request(), runSpec(child)) - const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) - await child.peer.nextMethod('initialized') - const threadStart = await child.peer.nextMethod('thread/start') - child.peer.respond(threadStart, { - thread: { id: 'thread-1', ephemeral: true }, - }) - const run = await starting - const result = await run.result - expect(result).toEqual({ - output: [], - diagnostic: expectedFailureDiagnostic('turn', 'unknown'), - stopReason: 'error', - }) - expect(result.diagnostic).not.toContain('SECRET_TOKEN') - runTurn.mockRestore() - await run.dispose() - } - }) - it('flattens child exit and protocol failures after publication', async () => { const errors: string[] = [] const outcomes: SubprocessOutcome[] = [ @@ -1549,9 +1505,45 @@ describe('run lifecycle and quiescence', () => { ) await run.dispose().catch(() => {}) } + { + const outcome = { exitCode: 17, signal: 'SIGABRT' } as const + const child = fakeChild({ exitOnTerminate: false }) + const { run, turnStart } = await publishRun(child, undefined, { + disposeGraceMs: 100, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.fromChild.emit('end') + setTimeout(() => { child.settle(outcome) }, 5) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('process', 'process-exit', { + outcome, + }), + stopReason: 'error', + }) + await run.dispose().catch(() => {}) + } + { + const child = fakeChild({ exitOnTerminate: false }) + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'other', + })) + await nextTask() + child.fromChild.emit('end') + child.settle({ exitCode: 17, signal: 'SIGABRT' }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn', 'other'), + stopReason: 'error', + }) + await run.dispose().catch(() => {}) + } { const child = fakeChild() const { run, turnStart } = await publishRun(child, undefined, { + disposeGraceMs: 10, onError: () => { throw new Error('diagnostic sink') }, }) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -1700,6 +1692,19 @@ describe('run lifecycle and quiescence', () => { .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN') + const asyncSpawnFailureChild = fakeChild({ + pid: -1, + doneError: new Error('SECRET_TOKEN async spawn failure'), + }) + const asyncSpawnFailure = startCodexRun( + request(), + runSpec(asyncSpawnFailureChild), + ) + await expect(asyncSpawnFailure) + .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) + await expect(asyncSpawnFailure).rejects.not.toThrow('SECRET_TOKEN') + expect(asyncSpawnFailureChild.terminate).not.toHaveBeenCalled() + const child = fakeChild() const starting = startCodexRun(request(), runSpec(child)) const initialize = await child.peer.nextMethod('initialize') @@ -1709,6 +1714,31 @@ describe('run lifecycle and quiescence', () => { await expect(starting).rejects.not.toThrow('invalid initialize response') expect(child.terminate).toHaveBeenCalledTimes(1) + const cleanupFailureChild = fakeChild({ + waitForExitError: new Error('SECRET_TOKEN wait failure'), + }) + const cleanupFailure = startCodexRun( + request(), + runSpec(cleanupFailureChild), + ) + const cleanupFailureInitialize = await cleanupFailureChild.peer + .nextMethod('initialize') + cleanupFailureChild.peer.respond(cleanupFailureInitialize, null) + const cleanupError: unknown = await cleanupFailure.then( + () => undefined, + (error: unknown) => error, + ) + expect(cleanupError).toBeInstanceOf(AggregateError) + expect(String(cleanupError)).toContain( + expectedFailureDiagnostic('initialize', 'unknown'), + ) + expect(String(cleanupError)).toContain(expectedFailureDiagnostic( + 'teardown', + 'unknown', + { outcome: { exitCode: 0, signal: null } }, + )) + expect(String(cleanupError)).not.toContain('SECRET_TOKEN') + const cleanupRaceAbort = new AbortController() const cleanupRaceChild = fakeChild({ exitOnTerminate: false }) const cleanupRace = startCodexRun( @@ -1796,27 +1826,6 @@ describe('run lifecycle and quiescence', () => { expect(child.terminate).toHaveBeenCalledTimes(1) }) - it('rolls back a subprocess done rejection during startup', async () => { - const child = fakeChild({ doneError: new Error('spawn observer failed') }) - const error: unknown = await startCodexRun(request(), runSpec(child)).then( - () => undefined, - (failure: unknown) => failure, - ) - expect(error).toBeInstanceOf(AggregateError) - if (!(error instanceof AggregateError)) { - throw new Error('expected startup and rollback failures') - } - expect(error.errors).toEqual([ - expect.objectContaining({ - message: `subagent-codex: ${expectedFailureDiagnostic('initialize', 'unknown')}`, - }), - expect.objectContaining({ - message: `subagent-codex: ${expectedFailureDiagnostic('teardown', 'unknown')}`, - }), - ]) - expect(child.terminate).toHaveBeenCalledTimes(1) - }) - it('keeps overlapping runs isolated', async () => { const initialStderrListeners = { error: process.stderr.listenerCount('error'), @@ -1957,7 +1966,6 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ - argv: codexAppServerArgv(), env: { OPENAI_API_KEY: 'fake' }, graceMs: 25, cwd: process.cwd(), @@ -2020,39 +2028,24 @@ describe('disposeCodexChild', () => { expect(child.waitForExit).not.toHaveBeenCalled() }) - it('reports direct-child observer failure and accepts absent stdin', async () => { - { - const child = fakeChild({ - doneError: new Error('close observer failed'), - }) - const wire = defaultWire(child) - await expect(disposeCodexChild(wire, child.handle)) - .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) - } - { - const child = fakeChild() - const handle = { ...child.handle, stdin: undefined } - const wire = defaultWire(child) - await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() - } + it('accepts absent stdin', async () => { + const child = fakeChild() + const handle = { ...child.handle, stdin: undefined } + const wire = defaultWire(child) + await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() }) - it('aggregates wire-close and tree-wait failures with safe teardown facts', async () => { + it('reports tree-wait failure with safe teardown facts', async () => { const child = fakeChild({ waitForExitError: new Error('SECRET_TOKEN wait failure'), }) const wire = defaultWire(child) - vi.spyOn(wire, 'close').mockImplementation(() => { - throw new Error('/private/secret.txt close failure') - }) const disposal = disposeCodexChild(wire, child.handle) - await expect(disposal).rejects.toBeInstanceOf(AggregateError) await expect(disposal).rejects.toThrow(expectedFailureDiagnostic( 'teardown', 'unknown', { outcome: { exitCode: 0, signal: null } }, )) await expect(disposal).rejects.not.toThrow('SECRET_TOKEN') - await expect(disposal).rejects.not.toThrow('/private/secret.txt') }) }) From 2442e63360da189f9647e81058a98bd8afec5fff Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:14:20 +0800 Subject: [PATCH 35/56] fix: cr --- ...08-18-tool-row-file-open-failure.i18n.yaml | 4 ++-- .../2026-08-18-tool-row-file-open-failure.md | 8 +++---- ...026-08-18-tool-row-file-open-failure.zh.md | 8 +++---- apps/web/tests/seeded-history.e2e.ts | 5 ++++- .../file-open-failure.expected.md | 7 +++++++ .../src/client/chat/ChatView.tsx | 21 +++++++++++++++---- .../src/client/contract/slots.ts | 7 ++++--- .../ui-conversation/src/client/locales.ts | 4 ++++ .../tests/apply-inject.client.spec.tsx | 2 +- .../tests/chat-view.client.spec.tsx | 14 ++++++++++++- 10 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml index 0e7aafe5bc..711034b02c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md -2026-08-18-tool-row-file-open-failure.md: 8b54fe323f1ea9dba302a9cb8cbf10944ac7ea3f -2026-08-18-tool-row-file-open-failure.zh.md: a969905729b6cccb510ca85bcc698ddbc33ccb54 +2026-08-18-tool-row-file-open-failure.md: e36552395b992e688fad35b3163b92c9f6189e43 +2026-08-18-tool-row-file-open-failure.zh.md: 72b6026d9eb44c74f13c987996e652008f35a78e diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md index 8b54fe323f..e36552395b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md @@ -12,9 +12,9 @@ The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.m ## Decision -The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or `fileOpen.unknown` when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog. +The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or the unknown-open fallback when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog. -The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. +The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. The produced-files folder action opens `.`, and that refusal uses the folder title and unknown-open copy. The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix. @@ -22,7 +22,7 @@ The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path - **Per-row inline error.** The Host call is conversation-owned and several entries share one opener; a row-local banner would duplicate the same refusal next to every click target. - **Toast without retry.** The product ask is the reason *and* a retry entry. The workspace folder-adoption dialog already pairs those two. -- **Chat-store persistence.** A failed open is transient view state. The chat store survives reload, so a leftover dialog would return after a refresh that cannot usefully retry the original gesture. +- **Chat-store remount persistence.** A failed open is transient view state. The chat store survives view remounts, so a leftover dialog would return after a tab switch that cannot usefully retry the original gesture. ## Consequences @@ -30,4 +30,4 @@ A silent Host refusal is no longer a success from the reader's seat. Headless or ## Testing -Package specs cover inject rejection, the dialog copy (Error, non-Error, empty), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row and asserts the English reason plus a second call with the same payload. +Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md index a969905729..72b6026d9e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用 `fileOpen.unknown`),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。 +inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用未知打开回退文案),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。 -对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。 +对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 `.`,该拒绝使用文件夹标题和未知打开回退文案。 Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 @@ -22,7 +22,7 @@ Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错 - **按行内联错误。** Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。 - **没有重试的 toast。** 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。 -- **写入 chat store 并持久化。** 打开失败是瞬时视图状态。chat store 会在刷新后存活,于是残留对话框会在无法有效重试原手势的刷新之后回来。 +- **写入 chat store 并跨 remount 保留。** 打开失败是瞬时视图状态。chat store 会在视图 remount 后存活,于是残留对话框会在无法有效重试原手势的页签切换之后回来。 ## 后果 @@ -30,4 +30,4 @@ Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错 ## 测试 -包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,并断言英文原因以及对同一 payload 的第二次调用。 +包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index ec12ddfeaf..30ad67f9ff 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -33,6 +33,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expecte // Command-row goldens over the same conversation after direct host commands. const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url)) const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/feedback-row.expected.md', import.meta.url)) +const FILE_OPEN_FAILURE_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/file-open-failure.expected.md', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'seeded-history-web-e2e' @@ -427,6 +428,8 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.click() const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) await dialog.waitFor({ timeout: 5_000 }) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FILE_OPEN_FAILURE_EXPECTED, snapshot, MODE) await expect.poll(() => dialog.innerText(), { timeout: 5_000 }) .toContain('path open failed: xdg-open is not available') await page.getByRole('button', { name: 'Retry' }).click() @@ -544,6 +547,6 @@ describe('web e2e: seeded history renders through cold resume', () => { // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'seed.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md new file mode 100644 index 0000000000..bd326673d9 --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md @@ -0,0 +1,7 @@ +- dialog "Couldn’t open file": + - heading "Couldn’t open file" [level=2] + - button "Close": + - img + - paragraph: path open failed: xdg-open is not available + - button "Cancel" + - button "Retry" diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index f2d2b98a43..80f9bc2d73 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -102,6 +102,11 @@ function openFailureMessage(error: unknown, fallback: string): string { return message === '' ? fallback : message } +/** ProducedFiles opens the session workspace as `.`. */ +function isFolderOpenPath(path: string): boolean { + return path === '.' +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -175,7 +180,7 @@ export function ChatView({ const requestOpenFile = useCallback((path: string) => { const id = ++fileOpenRequest.current setFileOpenBusy(true) - void Promise.resolve(openFile(path)).then( + void openFile(path).then( () => { if (id !== fileOpenRequest.current) return setFileOpenError(null) @@ -183,7 +188,13 @@ export function ChatView({ }, (error: unknown) => { if (id !== fileOpenRequest.current) return - setFileOpenError({ path, message: openFailureMessage(error, t('fileOpen.unknown')) }) + setFileOpenError({ + path, + message: openFailureMessage( + error, + t(isFolderOpenPath(path) ? 'fileOpen.folderUnknown' : 'fileOpen.unknown'), + ), + }) setFileOpenBusy(false) }, ) @@ -468,6 +479,7 @@ export function ChatView({ {fileOpenError !== null && ( void @@ -494,7 +507,7 @@ function FileOpenErrorDialog({ open onClose={onClose} closeLabel={t('close')} - title={t('fileOpen.title')} + title={t(isFolderOpenPath(path) ? 'fileOpen.folderTitle' : 'fileOpen.title')} description={message} footer={( <> diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index bdb34d51c0..6eb3442925 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -714,10 +714,11 @@ export interface ChatViewInjected { openDetails: (target: SelectionTarget) => void /** * Open a tool-arg filesystem path with the host OS default application - * (relative paths resolve against the session cwd). Rejects when the Host - * cannot hand the path off; the chat view shows that reason and a retry. + * (relative paths resolve against the session cwd). Always returns a + * promise: fulfills when the Host opens the path, rejects when it cannot + * hand the path off (the chat view shows that reason and a retry). */ - openFile: (path: string) => void | Promise + openFile: (path: string) => Promise loadOlder: () => void /** Resolve a session-authorized historical image for inline display. */ loadImage: (attachment: ImageAttachmentRef) => Promise diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index de851021b9..f1bc673458 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -94,6 +94,8 @@ export const zh = { 'chat.toBottom': '回到底部', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', + 'fileOpen.folderTitle': '无法打开文件夹', + 'fileOpen.folderUnknown': '无法打开此文件夹', 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', @@ -266,6 +268,8 @@ export const en = { 'chat.toBottom': 'Back to bottom', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', + 'fileOpen.folderTitle': 'Couldn’t open folder', + 'fileOpen.folderUnknown': 'Couldn’t open this folder', 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 38801e486a..cf07edaf31 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -244,7 +244,7 @@ describe('conversation slot inject API', () => { const b = await bench() b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available'))) const { injected } = b.chatViewApi(ROOT) - await expect(Promise.resolve(injected.openFile('src/a.ts'))).rejects.toThrow('xdg-open is not available') + await expect(injected.openFile('src/a.ts')).rejects.toThrow('xdg-open is not available') await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 04539b5d05..1cd7f01a14 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -152,7 +152,7 @@ function emptyWorkspaces() { function makeHarness(init?: Partial) { const { set, source } = makeSource(init) const openDetails = vi.fn<(t: SelectionTarget) => void>() - const openFile = vi.fn<(path: string) => void>() + const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const loadOlder = vi.fn() const inspectCall = vi.fn<(callId: string) => void>() // In-memory scroll memory matching the apply.ts per-session map contract. @@ -1021,6 +1021,18 @@ describe('ChatView', () => { }) }) + it('names a workspace-folder Host refusal as a folder', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('')) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('.') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件夹' }).textContent).toContain('无法打开此文件夹') + }) + }) + it('ignores a Host refusal that settles after the dialog is dismissed', async () => { let rejectRetry!: (error: unknown) => void const openFile = vi.fn<(path: string) => Promise>() From 20a5f5a3eec211f1a4ab5f6fdcc0e3862d26a344 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:25:13 +0800 Subject: [PATCH 36/56] feat(ui-tool): integrate connection handling for POSIX home path abbreviation --- .../2026-08-18-web-home-path-tilde.i18n.yaml | 6 +++ .../feature/2026-08-18-web-home-path-tilde.md | 37 +++++++++++++++ .../2026-08-18-web-home-path-tilde.zh.md | 37 +++++++++++++++ apps/web/tests/assembled-boot.ts | 4 +- apps/web/tests/home-path-tilde.snapshot.ts | 45 +++++++++++++++++++ .../workspace-hover.expected.txt | 2 + .../client/connection/src/client/fixture.ts | 11 ++++- .../client/connection/src/client/index.ts | 2 +- .../tests/connection.client.spec.ts | 10 ++--- .../connection/tests/fake-api.client.ts | 3 +- .../connection/tests/fixture.client.spec.ts | 18 +++++--- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/workspaces/path.ts | 25 ++++++++++- .../runtime/tests/client-apply.client.spec.ts | 4 +- .../client/runtime/tests/fake-api.client.ts | 3 +- .../client/runtime/tests/path.client.spec.ts | 41 +++++++++++++++++ .../runtime/tests/wire-events.client.spec.ts | 2 +- .../tests/produced-files.client.spec.tsx | 2 +- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- packages/client/ui-tool/package.json | 2 + packages/client/ui-tool/src/client/apply.ts | 15 ++++++- .../ui-tool/src/client/contract/slots.ts | 18 +++++++- packages/client/ui-tool/src/client/index.ts | 4 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 15 +++++-- .../ui-tool/src/client/tool/ToolDetails.tsx | 16 +++---- .../src/client/tool/models/read-card-model.ts | 10 +++-- .../src/client/tool/models/tool-call-model.ts | 8 +++- .../client/tool/toolviews/GenericToolCard.tsx | 6 +-- .../tool/toolviews/file-mutation-row.tsx | 4 +- .../src/client/tool/toolviews/read-row.tsx | 6 +-- .../ui-tool/tests/read-card.client.spec.tsx | 9 ++++ .../tests/tool-call-tree.client.spec.tsx | 1 + .../tests/tool-details-render.client.tsx | 8 +++- .../ui-tool/tests/tool-row.client.spec.tsx | 26 +++++++++++ packages/client/ui-tool/tsconfig.json | 3 ++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- packages/client/ui-workspace/package.json | 3 ++ .../src/client/WorkspaceBrowser.tsx | 8 +++- .../ui-workspace/src/client/contract/slots.ts | 9 ++++ .../client/ui-workspace/src/client/index.ts | 12 ++++- .../ui-workspace/src/client/rows/Rows.tsx | 15 +++++-- .../ui-workspace/tests/apply.client.spec.ts | 6 ++- .../tests/rename-assembly.client.spec.tsx | 3 ++ .../ui-workspace/tests/rows.client.spec.tsx | 38 ++++++++++++++++ .../tests/workspace-browser.client.spec.tsx | 1 + packages/client/ui-workspace/tsconfig.json | 3 ++ packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 2 + packages/host/apiproxy/src/api/host.schema.ts | 1 + packages/host/apiproxy/src/api/host.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 3 +- .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 2 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 7 ++- pnpm-lock.yaml | 3 ++ 64 files changed, 475 insertions(+), 82 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md create mode 100644 apps/web/tests/home-path-tilde.snapshot.ts create mode 100644 apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt create mode 100644 packages/client/runtime/tests/path.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml new file mode 100644 index 0000000000..e6983cb11f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md +2026-08-18-web-home-path-tilde.md: 4b9b24454bbeeb394480c0c30470b7383a257790 +2026-08-18-web-home-path-tilde.zh.md: d901caab361755de44f6384d1016faf125175822 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md new file mode 100644 index 0000000000..4b9b24454b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md @@ -0,0 +1,37 @@ +# Agent Note: Web UI abbreviates POSIX home paths as `~` + +Status: implemented + +English | [中文](2026-08-18-web-home-path-tilde.zh.md) + +## Problem + +Workspace hover cards and Tool call summaries showed full POSIX home paths. Those strings are long, repeat the same prefix on every row, and make the sidebar and transcript harder to scan. Windows paths must stay verbatim because `~` is not a Windows filesystem convention. + +## Decision + +`host.describe` reports the host account `home` as a required field. Client and Host ship together, so the field is required rather than optional. ApiProxy fills it from `homedir()` at describe time. + +`abbreviateHomePath` in `dsh-client-runtime` is the display-only helper. It returns `~` or `~/…` when the path is the POSIX home or a descendant, and leaves the path unchanged when `home` is missing, empty, or `/`, when either value is a Windows drive or UNC path, or when the match is only a prefix (`/Users/u` does not claim `/Users/u2`). Tool summaries run workspace-relative shortening first, then this helper, so a path inside the session cwd stays short. `filePath`, Host open, and Workspace hover copy keep the authored filesystem path. + +`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. A missing `hostDescription` on an incomplete test fake falls back to an absent source, so abbreviation does not run. + +The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/fixture/Documents/project` lets assembled replay hover `~/Documents/project` without moving the existing `/tmp/fixture` account. TerminalBlock's own prompt-label collapse is unchanged. + +## Alternatives considered + +**Guess `/Users` or `/home` without the real home.** Rejected because a shared prefix is not an account home, and `/Users/shared` or `/home/src` would abbreviate incorrectly. + +**Abbreviate Windows `%USERPROFILE%` as `~` as well.** Rejected because the acceptance rule keeps Windows paths verbatim, and `~` is not how Explorer or `cmd` spell those paths. + +**Put the helper in `dsh-home-paths`.** Rejected because that package expands configuration tildes on Node; this helper is a browser display rewrite and must not pull Node `os` into client bundles. + +**Thread `home` from ChatView owner props.** Rejected because it enlarges the conversation inject face and every ChatView test harness for a display fact only Tool and Workspace cards consume. + +## Consequences + +POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Incomplete test connection fakes without `hostDescription` render unabbreviated paths instead of hanging or throwing. + +## Testing + +Package tests cover `abbreviateHomePath`, `toolRowModel` / `readCardModel` home abbreviation, Workspace hover display versus copy, and `host.describe` schema plus live `homedir()`. Assembled replay `apps/web/tests/home-path-tilde.snapshot.ts` hovers the fixture home-descendant Workspace. Product-GUI PRs still record a real-browser GIF of the hover card. diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md new file mode 100644 index 0000000000..d901caab36 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web UI abbreviates POSIX home paths as `~` + +Status: implemented + +[English](2026-08-18-web-home-path-tilde.md) | 中文 + +## Problem + +Workspace 悬停卡片和 Tool 调用摘要会显示完整的 POSIX 家目录路径。这些字符串很长,每行重复同一前缀,侧边栏和对话记录更难扫读。Windows 路径必须保持原样,因为 `~` 不是 Windows 文件系统约定。 + +## Decision + +`host.describe` 把宿主账户的 `home` 作为必填字段上报。Client 与 Host 一同发布,因此该字段是必填而不是可选。ApiProxy 在 describe 时用 `homedir()` 填入。 + +`dsh-client-runtime` 中的 `abbreviateHomePath` 是仅用于展示的辅助函数。当路径是 POSIX 家目录或其后代时返回 `~` 或 `~/…`;`home` 缺失、为空或为 `/`,任一侧是 Windows 盘符或 UNC 路径,或只是前缀命中(`/Users/u` 不能收走 `/Users/u2`)时,路径保持不变。Tool 摘要先做工作区相对缩短,再调用该辅助函数,因此会话 cwd 内的路径仍然更短。`filePath`、Host 打开以及 Workspace 悬停复制仍使用作者给出的文件系统路径。 + +`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。测试假对象若缺少 `hostDescription`,会回退到空来源,因此不会进行缩写。 + +fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位于 `/home/fixture/Documents/project`,组装回放可以悬停出 `~/Documents/project`,而不必移动现有的 `/tmp/fixture` 账户。TerminalBlock 自有的提示符标签折叠保持不变。 + +## Alternatives considered + +**在没有真实 home 的情况下猜测 `/Users` 或 `/home`。** 否决,因为共享前缀不是账户家目录,`/Users/shared` 或 `/home/src` 会被错误缩写。 + +**同样把 Windows `%USERPROFILE%` 缩写成 `~`。** 否决,因为验收规则要求 Windows 路径保持原样,而且 Explorer 与 `cmd` 并不这样拼写这些路径。 + +**把辅助函数放进 `dsh-home-paths`。** 否决,因为该包在 Node 上展开配置里的波浪号;本辅助函数是浏览器展示改写,不能把 Node `os` 拉进 client 包。 + +**从 ChatView owner props 向下传递 `home`。** 否决,因为它会扩大 conversation 注入面和每一份 ChatView 测试夹具,而只有 Tool 与 Workspace 卡片消费这个展示事实。 + +## Consequences + +POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。缺少 `hostDescription` 的不完整测试连接假对象会渲染未缩写路径,而不是挂起或抛错。 + +## Testing + +包测试覆盖 `abbreviateHomePath`、`toolRowModel`/`readCardModel` 的家目录缩写、Workspace 悬停展示与复制,以及 `host.describe` schema 与实时 `homedir()`。组装回放 `apps/web/tests/home-path-tilde.snapshot.ts` 悬停 fixture 中位于家目录下的 Workspace。面向产品 GUI 的 PR 仍需录制悬停卡片的真实浏览器 GIF。 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 52c0658e4e..4e1ed1206f 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -30,7 +30,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', @@ -38,6 +38,8 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ url: '/plugins/ui-workspace.js', rev: 'fx', inject: [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar', diff --git a/apps/web/tests/home-path-tilde.snapshot.ts b/apps/web/tests/home-path-tilde.snapshot.ts new file mode 100644 index 0000000000..059a0ac003 --- /dev/null +++ b/apps/web/tests/home-path-tilde.snapshot.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +// Assembled POSIX home-path display: the fixture Host home is `/home/fixture` +// and a second Workspace lives under it. The sidebar hover card must show +// `~/Documents/project` while copy still writes the full path. +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt') + +installAssembledBootEnv() + +describe('assembled POSIX home-path display', () => { + it('shows the home-descendant Workspace path as ~ and copies the full path', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const group = (await within(tree).findAllByText('project')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group == null) throw new Error('home-descendant Workspace group missing') + + fireEvent.pointerEnter(group.parentElement as HTMLElement) + const hoverPath = await waitFor(() => { + const found = screen.getByText('~/Documents/project') + expect(found).toBeTruthy() + return found + }, { timeout: 2_000 }) + expect(screen.queryByText('/home/fixture/Documents/project')).toBeNull() + const copy = screen.getByRole('button', { name: 'Copy: /home/fixture/Documents/project' }) + + const shape = [ + `hover=${hoverPath.textContent}`, + `copy=${copy.getAttribute('aria-label')}`, + ].join('\n') + '\n' + if (REFRESHING_GOLDEN) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + act(() => { fireEvent.pointerLeave(group.parentElement as HTMLElement) }) + }) +}) diff --git a/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt b/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt new file mode 100644 index 0000000000..23ed9fb622 --- /dev/null +++ b/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt @@ -0,0 +1,2 @@ +hover=~/Documents/project +copy=Copy: /home/fixture/Documents/project diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dd0566486e..b0fbcfac22 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1551,6 +1551,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() + const FIXTURE_HOME = '/home/fixture' const workspaces: WorkspaceView[] = options.empty ? [] : [{ workspaceId: wid('fx-ws-fixture'), path: '/tmp/fixture', @@ -1558,6 +1559,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')], createdAt: fixtureEpoch, updatedAt: fixtureEpoch, + }, { + workspaceId: wid('fx-ws-home'), + path: `${FIXTURE_HOME}/Documents/project`, + title: 'project', + sessionIds: [], + createdAt: fixtureEpoch, + updatedAt: fixtureEpoch, }] let nextWorkspace = 1 // Registry-global archive set mirroring the host: archived sessions keep @@ -1568,7 +1576,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // deterministic content mirroring the design mock so assembled Web tests // and snapshots can walk it. Leaves are materialized lazily: a child listed // by its parent lists as empty until something is created inside it. - const FIXTURE_HOME = '/home/fixture' const directoryTree = new Map([ ['/', ['home']], ['/home', ['fixture']], @@ -2523,7 +2530,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, host: { describe: request => ok(request, { - version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true, + version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, }), // Deterministic native pick: the keyless lanes drive the full // pick-then-adopt path without an OS chooser (design-mock content, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index c4470b13aa..9847d48cdf 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -62,7 +62,7 @@ export interface ConnectionHandle { readonly api: IApiClient /** Whether the current page authority is loopback; non-browser contexts default to true. */ readonly isLoopback: boolean - /** Generation-scoped Host facts, including native path-open capability. */ + /** Generation-scoped Host facts, including the account home and native path-open capability. */ readonly hostDescription: HostDescriptionSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index e5290c0dbb..7965d627f4 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -80,7 +80,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff expect(connected).toBe(0) // never announced during the failed generation - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() @@ -102,7 +102,7 @@ describe('connection lifecycle', () => { }, }) } - return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -188,7 +188,7 @@ describe('connection lifecycle', () => { describeCalls++ return describeCalls === 1 ? firstDescribe.promise - : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) } const states: ConnectionState[] = [] let connected = 0 @@ -201,7 +201,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) }) api.endStreams() - firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(describeCalls).toBe(2) }) await vi.waitFor(() => { expect(connected).toBe(1) }) @@ -283,7 +283,7 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(3) }) - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission } finally { diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index bee4fc0ce0..7c9dc6accb 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -74,10 +74,11 @@ export class FakeApiClient implements IApiClient { version: string cwd: string attachedSessions: number + home: string canOpenPath: boolean }>> = () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true, + version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 11c4ddab8a..03a444739a 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -522,7 +522,9 @@ describe('createFixtureApi', () => { it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) - expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } }) + expect(response.result).toMatchObject({ + ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' }, + }) const empty = await createFixtureApi({ empty: true }).host.describe(req({})) expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) @@ -547,10 +549,16 @@ describe('createFixtureApi', () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.items).toEqual([expect.objectContaining({ - workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', - sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], - })]) + expect(listed.result.value.items).toEqual([ + expect.objectContaining({ + workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', + sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], + }), + expect.objectContaining({ + workspaceId: 'fx-ws-home', path: '/home/fixture/Documents/project', title: 'project', + sessionIds: [], + }), + ]) // path collision → the existing entity comes back, created:false, no frame. const reused = await api.workspace.create(req({ path: '/tmp/fixture' })) if (!reused.result.ok) throw new Error('reuse failed') diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index cd46900b77..ee19d184a2 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 1fb91bc8ca1bf9beae0ea12632acd9572db58670 -README.zh.md: 294a77d7081f5b475aca8b50f6c6d8322370375d +README.md: 51156bf60acebe0367ce7dedeb8d478b7f8fc148 +README.zh.md: 2c7838769367df1f4129064e3de2e64db5bb2eb4 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 1fb91bc8ca..51156bf60a 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,6 +26,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. +`abbreviateHomePath` is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged. + `indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. `SessionListState.jobsBySession` mirrors the Host's `session/jobs` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 294a77d708..2c78387693 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,6 +26,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。 +`abbreviateHomePath` 是 Web Workspace 悬停卡片与 Tool 摘要使用的仅展示 POSIX 家目录缩写;Windows 盘符或 UNC 路径保持原样,缺失、空或文件系统根的 home 不改写路径。 + `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 `SessionListState.jobsBySession` 按 last-wins 镜像宿主的 `session/jobs` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b5433bb573..24a584d746 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -46,7 +46,7 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts' -export { resolveWorkspacePath } from './workspaces/path.ts' +export { abbreviateHomePath, resolveWorkspacePath } from './workspaces/path.ts' // Contract only: the scope implementation and its Host transport belong to // dsh-client-ui-settings (see that package's settings-scope.ts). export type { diff --git a/packages/client/runtime/src/client/workspaces/path.ts b/packages/client/runtime/src/client/workspaces/path.ts index d1512177ad..8bb3aa6645 100644 --- a/packages/client/runtime/src/client/workspaces/path.ts +++ b/packages/client/runtime/src/client/workspaces/path.ts @@ -5,9 +5,32 @@ * @returns an absolute path when a workspace root is available, otherwise the original path. */ export function resolveWorkspacePath(cwd: string | undefined, path: string): string { - if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path + if (path.startsWith('/') || isWindowsStylePath(path)) return path if (cwd === undefined || cwd === '') return path const base = cwd.replace(/[/\\]+$/, '') const rel = path.replace(/^[/\\]+/, '') return `${base}/${rel}` } + +/** Drive-letter or UNC path; Web display must not rewrite these as `~`. */ +function isWindowsStylePath(value: string): boolean { + return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\') +} + +/** + * Display-only POSIX home abbreviation. Windows drive and UNC paths stay + * verbatim, including when `home` itself is a Windows path. A missing, empty, + * or filesystem-root `home` leaves `path` unchanged so `/` cannot become `~`. + * @param path - absolute or already-short display path. + * @param home - host account home from `host.describe`; absent skips abbreviation. + * @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`. + */ +export function abbreviateHomePath(path: string, home?: string): string { + if (home === undefined || home === '') return path + if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path + const root = home.replace(/\/+$/, '') + if (root === '' || root === '/') return path + if (path.replace(/\/+$/, '') === root) return '~' + if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}` + return path +} diff --git a/packages/client/runtime/tests/client-apply.client.spec.ts b/packages/client/runtime/tests/client-apply.client.spec.ts index 1026c55569..199181fe2e 100644 --- a/packages/client/runtime/tests/client-apply.client.spec.ts +++ b/packages/client/runtime/tests/client-apply.client.spec.ts @@ -91,7 +91,7 @@ describe('runtime client apply', () => { expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') // Mux sink and onConnected route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }) + bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) }) it('selects the recent Workspace once when the first baselines have no current session', async () => { @@ -104,7 +104,7 @@ describe('runtime client apply', () => { })) bench.api.onList = () => Promise.resolve(ok({ items: [] })) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }) + bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) await flushMicrotasks() const sessions = bench.ctx.get('sessions') as SessionRuntime diff --git a/packages/client/runtime/tests/fake-api.client.ts b/packages/client/runtime/tests/fake-api.client.ts index 33a0efbbfd..e5bdaa5f66 100644 --- a/packages/client/runtime/tests/fake-api.client.ts +++ b/packages/client/runtime/tests/fake-api.client.ts @@ -108,10 +108,11 @@ export class FakeApiClient implements IApiClient { version: string cwd: string attachedSessions: number + home: string canOpenPath: boolean }>> = () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true, + version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) diff --git a/packages/client/runtime/tests/path.client.spec.ts b/packages/client/runtime/tests/path.client.spec.ts new file mode 100644 index 0000000000..455df0d112 --- /dev/null +++ b/packages/client/runtime/tests/path.client.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/workspaces/path.ts' + +describe('abbreviateHomePath', () => { + it('collapses a POSIX home and its descendants', () => { + expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/Documents/project', '/Users/u')).toBe('~/Documents/project') + expect(abbreviateHomePath('/Users/u/Documents/project/', '/Users/u/')).toBe('~/Documents/project/') + }) + + it('keeps prefix-adjacent names and non-home paths', () => { + expect(abbreviateHomePath('/Users/u2/a.ts', '/Users/u')).toBe('/Users/u2/a.ts') + expect(abbreviateHomePath('/etc/hosts', '/Users/u')).toBe('/etc/hosts') + expect(abbreviateHomePath('src/a.ts', '/Users/u')).toBe('src/a.ts') + expect(abbreviateHomePath('~/already', '/Users/u')).toBe('~/already') + }) + + it('does not abbreviate when home is missing, empty, or the filesystem root', () => { + expect(abbreviateHomePath('/Users/u/a.ts')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/Users/u/a.ts', '')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/etc/hosts', '/')).toBe('/etc/hosts') + expect(abbreviateHomePath('/etc/hosts', '///')).toBe('/etc/hosts') + }) + + it('leaves Windows drive and UNC paths verbatim', () => { + expect(abbreviateHomePath('C:\\Users\\u\\project', 'C:\\Users\\u')).toBe('C:\\Users\\u\\project') + expect(abbreviateHomePath('C:/Users/u/project', '/Users/u')).toBe('C:/Users/u/project') + expect(abbreviateHomePath('/Users/u/project', 'C:\\Users\\u')).toBe('/Users/u/project') + expect(abbreviateHomePath('\\\\server\\share\\u', '\\\\server\\share\\u')).toBe('\\\\server\\share\\u') + }) +}) + +describe('resolveWorkspacePath', () => { + it('joins a relative path under cwd and passes absolute paths through', () => { + expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') + expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') + expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') + expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') + }) +}) diff --git a/packages/client/runtime/tests/wire-events.client.spec.ts b/packages/client/runtime/tests/wire-events.client.spec.ts index f6798bd914..13365c1462 100644 --- a/packages/client/runtime/tests/wire-events.client.spec.ts +++ b/packages/client/runtime/tests/wire-events.client.spec.ts @@ -127,7 +127,7 @@ describe('wire event bridge', () => { const bench = await mount() let resets = 0 bench.ctx.on('connection/reset', () => { resets++ }) - const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true } + const description = { version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true } bench.sinks?.onConnected?.(description) bench.sinks?.onConnected?.(description) // second generation after a reconnect expect(resets).toBe(2) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 1521010497..b37e65c9ea 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -286,7 +286,7 @@ describe('ProducedFiles row', () => { ): Pick => { const description = canOpenPath === undefined ? undefined - : { version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath } + : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath } return { isLoopback, useHostDescription: selector => selector(description), diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index b41ee17acc..678a276a73 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md -README.md: 6e2bef2f5ad4b136510c3acbb8f8b8e83c4c9212 -README.zh.md: 169417747541db9e02cb552b175ca7c100420aeb +README.md: b87236309c9bafe3e35d3d5977d56bd62a24de31 +README.zh.md: 3bae0b3f4cb3ad695371ec7a66fb531a935a4d2f diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 6e2bef2f5a..b87236309c 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd`, and plain `openFile`/`inspect` callbacks. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge. +The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. Path summaries relativize to the session cwd first, then replace a leftover POSIX host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge. This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 1694177475..3bae0b3f4c 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd`,以及普通的 `openFile`、`inspect` 回调。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。 +owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX 宿主家目录写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。 本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 991e0d844e..01f1b3b1fb 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" @@ -50,6 +51,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index ec2f0b8ec1..a226c5116b 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,4 +1,5 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ +import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { ToolCallTree } from './tool/ToolCallTree.tsx' @@ -12,14 +13,22 @@ import { searchToolview } from './tool/toolviews/search-row.tsx' import { todoToolview } from './tool/toolviews/todo-row.tsx' import { webToolview } from './tool/toolviews/web-row.tsx' -/** Required service: the slot registry that owns both Tool render seats. */ -export const inject = ['slots'] +/** Required services: the slot registry and the Host description used for POSIX `~`. */ +export const inject = ['slots', 'connection'] + +const absentHostDescription: HostDescriptionSource = { + getSnapshot: () => undefined, + subscribe: () => () => {}, +} /** * Mount the whole-Tool renderers and built-in atomic Tool registrations. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { + const connection = ctx.get('connection') as ConnectionHandle + const hostDescription = connection.hostDescription ?? absentHostDescription + const toolInject = () => ({ hooks: { hostDescription } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', @@ -27,11 +36,13 @@ export function apply(ctx: ClientContext): void { children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, }, + inject: toolInject, }, ToolCallTree)) ctx.slots.inject('conversation.details.tool', () => ctx.slots.register({ name: 'conversation.details.tool', locale: NS, + inject: toolInject, }, ToolDetails)) ctx.plugin(bashToolviewSample) diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 04af306db4..a6cdd1fa2d 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,6 @@ /** Tool UI slot declarations and their composed component props. */ -import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -34,6 +35,8 @@ export interface ToolCallOwnerProps { block: ToolCallBlock /** Session workspace root for relative summaries. */ cwd?: string | undefined + /** Host account home; POSIX home-rooted summaries display as `~`. */ + home?: string | undefined /** Open a Tool argument path through the Host. */ openFile: (path: string) => void /** Inspect this call in the trajectory view when available. */ @@ -43,10 +46,21 @@ export interface ToolCallOwnerProps { /** Full props of a registered atomic Tool view. */ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> +/** Injected Host description for POSIX home-path display. */ +export type ToolHostDescriptionInjected = { + hooks: { + /** Current generation's Host description, bound by the slot renderer. */ + hostDescription: HostDescriptionSource + } +} + /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ -export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> +export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> + & PropsLocale<'conversation'> + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 357506b1db..2079d09a96 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,3 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' -export type { ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolTreeProps } from './contract/slots.ts' +export type { + ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps, +} from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index db3ed0af06..3ed5fb9216 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -12,12 +12,13 @@ function callName(node: ToolCallBlock): string { /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, block, openFile, selected, cwd, inspectCall, t, children, + renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, t, children, }: Pick & { callId: string toolName: string block: ToolCallBlock selected: boolean + home?: string | undefined children?: ReactNode }) { const owner: ToolCallOwnerProps = useMemo(() => ({ @@ -26,8 +27,9 @@ const ToolCall = memo(function ToolCall({ block, openFile, cwd, + home, inspect: () => { inspectCall(callId) }, - }), [callId, toolName, block, openFile, cwd, inspectCall]) + }), [callId, toolName, block, openFile, cwd, home, inspectCall]) return (
& { block: ToolCallBlock + home?: string | undefined }) { return ( @@ -70,6 +74,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ block={child} selectedCallId={selectedCallId} cwd={cwd} + home={home} openFile={openFile} inspectCall={inspectCall} t={t} @@ -88,8 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostDescription, t, }: ToolTreeProps) { + const home = useHostDescription(description => description?.home) const block = node.data.root return ( ) { + const home = useHostDescription(description => description?.home) const terminal = terminalCardModel(block, cwd) if (terminal !== null) { return ( @@ -34,7 +30,7 @@ export function ToolDetails({ block, cwd, t }: ToolDetailsContentProps) { ) } - const read = readCardModel(block, cwd) + const read = readCardModel(block, cwd, home) if (read !== null) return const diff = diffCardModel(block) if (diff !== null) return diff --git a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts index 62a591bb17..e79a6979b8 100644 --- a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts @@ -13,6 +13,7 @@ * until the result arrives. * @module */ +import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' @@ -52,14 +53,15 @@ export type ReadCardModel = Pick ({ number: line.number, text: line.text })) return { - label: result.title ?? relativizeToCwd(result.path, sessionCwd), + label: result.title ?? abbreviateHomePath(relativizeToCwd(result.path, sessionCwd), home), lines, totalLines: result.totalLines, lang: result.lang, diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index fa7cb27962..3193d054b2 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -9,6 +9,7 @@ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer // that produces the values). +import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -206,16 +207,19 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { * @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param cwd - session workspace root; workspace-rooted path summaries display relative to it. + * @param home - host account home; a leftover POSIX home path displays as `~`. * @returns the row model. */ -export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel { +export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string, home?: string): ToolRowModel { const variant = classifyTool(toolName) const done = 'kind' in block const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const state: ToolRowState = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' - const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd) + const base = argsRaw === '' + ? block.callId + : abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home) const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool // name rides the mutable summary slot unless the tool owns a specific title. diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index ae4d18eec8..99fb68b9af 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -33,10 +33,10 @@ export interface GenericToolCardProps extends ToolCallOwnerProps { t: ToolTreeProps['t'] } -export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) { - const model = toolRowModel(toolName, block, cwd) +export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, t }: GenericToolCardProps) { + const model = toolRowModel(toolName, block, cwd, home) const terminal = terminalCardModel(block, cwd) - const read = readCardModel(block, cwd) + const read = readCardModel(block, cwd, home) const diff = diffCardModel(block) const search = searchCardModel(block) const web = webCardModel(block) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index 9616dab7cd..ad99ed7999 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -29,8 +29,8 @@ type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'> * model-facing error text through its Output section and its first line in the * collapsed summary instead. */ -export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: FileMutationRowProps) { - const model = toolRowModel(toolName, block, cwd) +export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, t }: FileMutationRowProps) { + const model = toolRowModel(toolName, block, cwd, home) const diff = diffCardModel(block) return ( * read card as the row's collapsed-by-default card body. The summary path is an * openable host link when the row names a single file. */ -export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) { - const model = toolRowModel(toolName, block, cwd) - const read = readCardModel(block, cwd) +export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: ReadRowProps) { + const model = toolRowModel(toolName, block, cwd, home) + const read = readCardModel(block, cwd, home) return ( { .toBe('/w/app/src/a.ts') }) + it('abbreviates a leftover POSIX home path label', () => { + expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label) + .toBe('~/notes.md') + expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label) + .toBe('src/a.ts') + expect(readCardModel(settled({ resultView: resultRead({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label) + .toBe('C:\\Users\\u\\a.ts') + }) + it('carries an omitted language through as undefined', () => { const noLang = resultRead() delete (noLang as { lang?: string }).lang diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 44f4231802..441052556d 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -44,6 +44,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), + useHostDescription: (selector => selector(undefined)) as ToolTreeProps['useHostDescription'], t, } as unknown as ToolTreeProps } diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index b2b2cd9ac7..5e70747b98 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -4,6 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' +import type { ToolDetailsProps } from '../src/client/contract/slots.ts' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' /** Framework session-area seat used by direct DetailsPanel tests. */ @@ -58,6 +59,11 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr // PropsRenderSlots keeps its key generic even for this one-key share; // recover the concrete owner selected by the adapter's fixed slot. const details = owner as unknown as DetailsToolOwnerProps - return + return selector(undefined)) as ToolDetailsProps['useHostDescription']} + t={t} + /> } } diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 28c8b5b236..bedea5d720 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -137,6 +137,32 @@ describe('tool-call-model', () => { expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md') }) + it('abbreviates leftover POSIX home paths after cwd relativization', () => { + const home = '/Users/u' + const cwd = '/tmp/ws' + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u"}' }), cwd, home).summary).toBe('~') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/notes.md"}' }), cwd, home).summary) + .toBe('~/notes.md') + // Workspace-relative wins: a home-and-cwd descendant stays short, not `~/…`. + expect(toolRowModel( + 'read', + running({ name: 'read', argsRaw: '{"path":"/Users/u/proj/src/a.ts"}' }), + '/Users/u/proj', + home, + ).summary).toBe('src/a.ts') + // Prefix boundary: `/Users/u2` is not under `/Users/u`. + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u2/a.ts"}' }), cwd, home).summary) + .toBe('/Users/u2/a.ts') + expect(toolRowModel( + 'read', + running({ name: 'read', argsRaw: '{"path":"C:\\\\Users\\\\u\\\\a.ts"}' }), + cwd, + home, + ).summary).toBe('C:\\Users\\u\\a.ts') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/a.ts"}' }), cwd).summary) + .toBe('/Users/u/a.ts') + }) + it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => { expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 2bd46fec7b..1982d6eee1 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 6d5eff023b..ff81e52445 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 8878aa49dcccd60ddcde5f0a9563bbfbd969c9c0 -README.zh.md: c83f1c53d5471dd8d52b933163a573eb98a70c2e +README.md: fb26b0386f729863514862ccb819cb3f99e96dc0 +README.zh.md: a32c5e2ca343c21b6a156da25807c5a96162e03f diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 8878aa49dc..fb26b0386f 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -8,7 +8,7 @@ The browser renders grouped or flat Session rows from the global runtime hooks a Collapsed search is one header action beside the view and add actions. In the rail, add and search render as 36px controls on the shell's shared horizontal entry path. Activating search expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail shows a POSIX home or descendant as `~` / `~/…` and leaves a Windows path verbatim. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c83f1c53d5..a32c5e2ca3 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -8,7 +8,7 @@ 折叠搜索是视图和添加操作旁的一枚区头按钮。在轨道中,添加和搜索会渲染为沿外壳共用横向进入路径移动的 36px 控件。激活搜索后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情把 POSIX 家目录及其后代显示为 `~`/`~/…`,Windows 路径保持原样。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index e76b9e138c..f130a4eea9 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", @@ -49,6 +50,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -58,6 +60,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index d0f6835472..61f6c81bab 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -218,6 +218,8 @@ type SessionTreeProps = Pick< 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' > & { + /** Host account home for POSIX hover-path abbreviation. */ + home?: string | undefined workspaces: readonly WorkspaceView[] /** Explicit persisted zero-or-five-session state by Workspace group. */ groupExpansion: Readonly> @@ -251,7 +253,7 @@ function SessionTree({ onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, - sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t, + sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current @@ -450,6 +452,7 @@ function SessionTree({ > { if (group.expanded) { @@ -758,9 +761,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, + useHostDescription, renderSlot, t, }: WorkspaceBrowserProps) { + const home = useHostDescription(description => description?.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) @@ -1152,6 +1157,7 @@ export function WorkspaceBrowser({ insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} orderBy={orderBy} + home={home} t={t} onRenameRequest={(workspaceId, currentTitle) => { setRenameTarget({ workspaceId, currentTitle }) diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 8027a3623a..3714a4fb98 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,6 +22,7 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ +import type { HostDescription, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. @@ -90,6 +91,10 @@ export type DirectoryPickingHooks = { * browsing region drives. */ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { + hooks: DirectoryPickingInjected['hooks'] & { + /** Current generation's Host description, bound by the slot renderer. */ + hostDescription: HostDescriptionSource + } /** * Start a New Session in a Workspace: reuse-or-create its blank session and * open it; without an explicit workspace, inherit the current Session @@ -144,6 +149,10 @@ export type WorkspaceBrowserProps = & PropsStore> & Omit & DirectoryPickingHooks + & { + /** Selector hook over the current generation's Host description. */ + useHostDescription: SnapshotSelectorHook + } & PropsLocale<'workspace'> /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 6b14243ecf..b149121fff 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -8,6 +8,7 @@ * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ +import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -42,7 +43,12 @@ const NS = 'workspace' * provides a waitable service. apply therefore depends on each slot * declaration through `slots.inject()` instead of assuming order. */ -export const inject = ['slots', 'sessions', 'workspaces', 'locale'] +export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'] + +const absentHostDescription: HostDescriptionSource = { + getSnapshot: () => undefined, + subscribe: () => () => {}, +} /** * Register the browser and picker once their slot declarations are on the @@ -51,6 +57,8 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + const connection = ctx.get('connection') as ConnectionHandle + const hostDescription = connection.hostDescription ?? absentHostDescription ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => { @@ -99,7 +107,7 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), - hooks: { directoryFlow: browserFlowSource }, + hooks: { directoryFlow: browserFlowSource, hostDescription }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 481e0f0e47..d2d121fe4d 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -13,6 +13,7 @@ import { IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' +import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import { relativeTime } from '../tree.ts' @@ -50,7 +51,7 @@ function createdLabel(createdAt: number, t: RowTranslate): string { return t('hover.created', { time: `${date} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` }) } -/** Hover-card body: workspace title, full directory path, absolute creation time. */ +/** Hover-card body: workspace title, display directory path, absolute creation time. */ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { label: string cwd: string | undefined @@ -104,10 +105,11 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | * @param props.onToggle - expand/collapse the group. * @param props.onCreate - start a frontend Session inside this Workspace. * @param props.drag - optional workspace-row drag wiring. + * @param props.home - host account home for POSIX hover-path abbreviation. * @param props.t - the browser root's locale seat. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, t }: { group: GroupNode onToggle: () => void onCreate: () => void @@ -115,6 +117,8 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: actions?: { rename: () => void; delete: () => void } | undefined /** Present only for real Workspace rows in the grouped view. */ drag?: WorkspaceRowDragProps | undefined + /** Host account home; POSIX home-rooted hover paths display as `~`. */ + home?: string | undefined t: RowTranslate }) { const row = group @@ -196,7 +200,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: return ( } + content={} disabled={menuOpen} copyText={row.cwd} copyLabel={t('copy')} diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 016af313f8..2f947d145d 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -36,6 +36,9 @@ async function bench() { create, startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never) + ctx.provide('connection', { + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) return { @@ -54,7 +57,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale']) + expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale', 'connection']) }) it('registers browser and pickers for declarations arriving before or after apply', async () => { @@ -126,6 +129,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) + expect(browser.hooks.hostDescription.getSnapshot()).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index e61c997de1..5359678050 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -31,6 +31,9 @@ beforeEach(() => { localStorage.clear() }) /** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index c7a153ff5f..96f36faefc 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -304,6 +304,44 @@ describe('workspace browser rows', () => { } }) + it('workspace hover card shows a POSIX home descendant as ~ and still copies the full path', async () => { + vi.useFakeTimers() + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/home/u/Documents/project', createdAt: 0, label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('~/Documents/project')).toBeTruthy() + expect(screen.queryByText('/home/u/Documents/project')).toBeNull() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /home/u/Documents/project' })) }) + expect(writeText).toHaveBeenCalledWith('/home/u/Documents/project') + } finally { + restoreClipboard() + vi.useRealTimers() + } + }) + + it('workspace hover card leaves a Windows path verbatim', async () => { + vi.useFakeTimers() + try { + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: 'C:\\Users\\u\\project', createdAt: 0, label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('C:\\Users\\u\\project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('ungrouped bucket renders no workspace menu', () => { const group: GroupNode = { key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped', diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index fbe42fc279..2a813f89f8 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -80,6 +80,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), + useHostDescription: selector => selector(undefined), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 32e8aa89ec..e419270fb8 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-primitives" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../runtime" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5826f75f75..b658b72b5b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 607cd4e4176631b64daf4a298b5d86a75ccdce68 -README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433 +README.md: 27efe5a75eb6947d71f95c0a60e590c15a35887c +README.zh.md: 71f4e63a013170c6822a5c8dae28bc5f899b271a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 607cd4e417..27efe5a75e 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,7 +50,7 @@ A stale continuation discards every partial result, deduplication entry, and cur Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. -`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.home` is the host account home directory. The Web client uses it to display POSIX home-rooted paths as `~`; Windows values are still reported and are not abbreviated. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action. The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cdfb5aa65b..71f4e63a01 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 -`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.home` 是宿主账户的家目录。Web 客户端用它把 POSIX 家目录路径显示为 `~`;Windows 值仍会上报,但不会缩写。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。 `agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录的工具调用,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c675b60907..b161d28d33 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' +import { homedir } from 'node:os' import { dirname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' @@ -2874,6 +2875,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro provider: selection.provider, model: selection.model, attachedSessions: ctx.agents.list().length, + home: homedir(), canOpenPath: canOpenPaths(), })) }, diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 7dd560d3a4..1a520f1e30 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,6 +17,7 @@ export const hostDescribeValueSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), + home: z.string(), canOpenPath: z.boolean(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 33c55106f5..5e45fcf3a5 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -41,6 +41,7 @@ export interface HostApi { * applied when a new agent doesn't specify them explicitly, absent when the host configures * no explicit default (the adapter falls back internally); * attachedSessions = count of currently attached sessions (those with a live agent); + * home = the host account home directory (Web display abbreviation on POSIX); * canOpenPath = whether this deployment can hand a path to a user-visible native desktop. */ describe(request: RpcRequest<{}>): Promise> diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 54bdb015e4..efbb16d682 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -234,6 +234,7 @@ describe('host.openPath', () => { const headless = await harness(undefined, undefined, { canOpenPath: () => false }) expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) + expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) }) it('opens through the injected native boundary', async () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 4130d8f210..de9d4ddab0 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -72,7 +72,7 @@ function scriptedApi(overrides: { }, host: { describe: r => ok(r, { - version: '0-test', cwd: '/t', attachedSessions: 0, canOpenPath: true, + version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, }), pickDirectory: r => ok(r, { path: null }), listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 2000f708ba..77432c55af 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -143,7 +143,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra rpcId: request.rpcId, result: { ok: true, - value: { version: 'v', cwd: '/w', attachedSessions: 0, canOpenPath: true }, + value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true }, }, } }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 66ae52f783..62d1a371da 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -312,15 +312,18 @@ describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) const value = hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true, + version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, home: '/h', canOpenPath: true, }) expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true }) expect(hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: false, + version: '1', cwd: '/x', attachedSessions: 0, home: '/h', canOpenPath: false, }).provider).toBeUndefined() expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, })).toThrow() + expect(() => hostDescribeValueSchema.parse({ + version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true, + })).toThrow() }) it('validates the browse listing/creation payloads', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 436891f7c2..dcc95d35eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3032,6 +3032,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale From dd3ea3db2e37479086c378d96941cf7b2a966548 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:31:12 +0800 Subject: [PATCH 37/56] fix: ci --- apps/web/tests/seeded-history.e2e.ts | 4 ++++ .../client/ui-conversation/tests/apply-inject.client.spec.tsx | 2 +- .../client/ui-conversation/tests/chat-view.client.spec.tsx | 2 +- .../ui-conversation/tests/views-type-chain.client.spec.tsx | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 30ad67f9ff..a7220857ad 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -440,6 +440,10 @@ describe('web e2e: seeded history renders through cold resume', () => { timeout: 5_000, }).toBe(0) } finally { + // Shared page: a leftover mask blocks later cases even when this one fails. + if (await page.getByRole('dialog', { name: 'Couldn’t open file' }).count() > 0) { + await page.keyboard.press('Escape') + } openPath.mockRestore() } }) diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index cf07edaf31..fcd533c1bf 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -233,7 +233,7 @@ describe('conversation slot inject API', () => { it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { const b = await bench() const { injected } = b.chatViewApi(ROOT) - injected.openFile('src/a.ts') + await injected.openFile('src/a.ts') await vi.waitFor(() => { expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) }) diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 1cd7f01a14..b81dff8e6e 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -348,7 +348,7 @@ describe('Chat node rendering', () => { resolve: (value) => { if (value !== 'report.html') return undefined return { - open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, + open: () => { void h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, label: '打开 site/report.html', title: 'site/report.html', } diff --git a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx index 1e6d41c261..6f90658c5f 100644 --- a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx +++ b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx @@ -45,7 +45,7 @@ describe('view-ring type negatives (compile-time; body never runs)', () => { // @ts-expect-error openDetails takes a SelectionTarget, not a string props.openDetails('nope') // @ts-expect-error openFile takes a path string, not a SelectionTarget - props.openFile({ turnSeq: 1, callId: 'c' }) + void props.openFile({ turnSeq: 1, callId: 'c' }) return null } void chatProps From 6a5118eb5c474a9afb1331cb36e65efec79df9ca Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:44:03 +0800 Subject: [PATCH 38/56] fix: ci --- .../snapshots/seeded-history/file-open-failure.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md index bd326673d9..fece247a7a 100644 --- a/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md +++ b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md @@ -2,6 +2,6 @@ - heading "Couldn’t open file" [level=2] - button "Close": - img - - paragraph: path open failed: xdg-open is not available + - paragraph: "path open failed: xdg-open is not available" - button "Cancel" - button "Retry" From b8670e3fbe17fcf55499aa76768f8fde93ee8ba0 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:58:55 +0800 Subject: [PATCH 39/56] fix: ci --- .../ui-workspace/tests/rows.client.spec.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 96f36faefc..f86e275c5e 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -326,6 +326,24 @@ describe('workspace browser rows', () => { } }) + it('workspace hover card without a directory omits the path and copy action', async () => { + vi.useFakeTimers() + try { + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: undefined, createdAt: 0, label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('Project')).toHaveLength(2) + expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy() + expect(screen.queryByRole('button', { name: /^复制:/ })).toBeNull() + } finally { + vi.useRealTimers() + } + }) + it('workspace hover card leaves a Windows path verbatim', async () => { vi.useFakeTimers() try { From b032097c2a651d0ca231f9c4f3ac212376615a54 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 20:02:37 +0800 Subject: [PATCH 40/56] fix(subagent): preserve terminal turn precedence --- packages/subagent/subagent-codex/src/run.ts | 17 +++++----------- .../tests/subagent-codex.spec.ts | 20 +++++++++++++++++-- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 61cfee871e..959aebaa7c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -331,10 +331,7 @@ export async function startCodexRun( const result: Promise = settleRunResult({ attempt: async () => { try { - const terminal = await Promise.race([ - wire.runTurn(texts, runAbort.signal), - processFailure, - ]) + const terminal = await wire.runTurn(texts, runAbort.signal) if (terminal.stopReason === 'completed') return terminal const facts = wire.collectFailure() return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } @@ -357,15 +354,11 @@ export async function startCodexRun( // The wire failure remains authoritative when exit observation fails. } } - const facts = error instanceof CodexRunFailure - ? error.facts - : endedBeforeTerminal && processFailureFacts !== undefined - ? processFailureFacts - : wire.collectFailure() + const facts = endedBeforeTerminal && processFailureFacts !== undefined + ? processFailureFacts + : wire.collectFailure() recordFailureDiagnostic(facts) - throw error instanceof CodexRunFailure - ? error - : new CodexRunFailure(facts, thrown(error)) + throw new CodexRunFailure(facts, thrown(error)) } }, collectOutput, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 8c7acc81e1..7fa6ee6416 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1612,6 +1612,7 @@ describe('run lifecycle and quiescence', () => { onError: (error) => { errors.push(error.message) }, }) child.settle(outcome) + child.fromChild.emit('end') await expect(run.result).resolves.toEqual({ output: [], diagnostic: expectedFailureDiagnostic('process', 'process-exit', { @@ -1649,8 +1650,7 @@ describe('run lifecycle and quiescence', () => { child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { codexErrorInfo: 'other', })) - await nextTask() - child.fromChild.emit('end') + child.fromChild.end() child.settle({ exitCode: 17, signal: 'SIGABRT' }) await expect(run.result).resolves.toEqual({ output: [], @@ -1659,6 +1659,22 @@ describe('run lifecycle and quiescence', () => { }) await run.dispose().catch(() => {}) } + { + const child = fakeChild({ exitOnTerminate: false }) + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + child.fromChild.end() + child.settle({ exitCode: 17, signal: 'SIGABRT' }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + await run.dispose().catch(() => {}) + } { const child = fakeChild() const { run, turnStart } = await publishRun(child, undefined, { From 996f6e49a5ca880ce6ea57aa75b9b8c2d8640a2a Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 20:04:18 +0800 Subject: [PATCH 41/56] fix: ci --- packages/client/ui-tool/src/client/apply.ts | 10 ++-------- .../ui-tool/tests/assembly-surfaces.client.spec.tsx | 6 +++++- .../ui-tool/tests/chat-code-subcalls.client.spec.tsx | 6 +++++- .../ui-tool/tests/tool-details-render.client.tsx | 3 +-- .../ui-tool/tests/toolview-slot.client.spec.tsx | 12 ++++++++++-- packages/client/ui-workspace/src/client/index.ts | 9 ++------- .../tests/rename-assembly.client.spec.tsx | 2 +- 7 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index a226c5116b..a2cc912d0d 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,5 +1,5 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ -import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { ToolCallTree } from './tool/ToolCallTree.tsx' @@ -16,19 +16,13 @@ import { webToolview } from './tool/toolviews/web-row.tsx' /** Required services: the slot registry and the Host description used for POSIX `~`. */ export const inject = ['slots', 'connection'] -const absentHostDescription: HostDescriptionSource = { - getSnapshot: () => undefined, - subscribe: () => () => {}, -} - /** * Mount the whole-Tool renderers and built-in atomic Tool registrations. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - const hostDescription = connection.hostDescription ?? absentHostDescription - const toolInject = () => ({ hooks: { hostDescription } }) + const toolInject = () => ({ hooks: { hostDescription: connection.hostDescription } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 501d4c27b9..44a820a110 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -68,7 +68,11 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index cb4325a09e..d73c09013c 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -158,7 +158,11 @@ async function bench(snapshot: ConversationSnapshot) { } ctx.provide('workspaces', workspaces) ctx.provide('layout', layout) - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index 5e70747b98..7d3ffefe7c 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -4,7 +4,6 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' -import type { ToolDetailsProps } from '../src/client/contract/slots.ts' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' /** Framework session-area seat used by direct DetailsPanel tests. */ @@ -62,7 +61,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr return selector(undefined)) as ToolDetailsProps['useHostDescription']} + useHostDescription={selector => selector(undefined)} t={t} /> } diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 09ec2f0d3f..bdfba88345 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -64,7 +64,11 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -203,7 +207,11 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index b149121fff..93269c22c1 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -8,7 +8,7 @@ * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ -import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -45,11 +45,6 @@ const NS = 'workspace' */ export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'] -const absentHostDescription: HostDescriptionSource = { - getSnapshot: () => undefined, - subscribe: () => () => {}, -} - /** * Register the browser and picker once their slot declarations are on the * ledger. Inject factories return plain callbacks; data reads use the @@ -58,7 +53,7 @@ const absentHostDescription: HostDescriptionSource = { */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - const hostDescription = connection.hostDescription ?? absentHostDescription + const hostDescription = connection.hostDescription ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => { diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index 5359678050..c2e201469f 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -33,7 +33,7 @@ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, - } as never) + }) const locale = new LocaleRuntime(runtime.ctx) runtime.provide('locale', locale) runtime.slots.installLocale(locale) From 51c86af1e9c1434874705f30bdf29f2345964fac Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 20:09:55 +0800 Subject: [PATCH 42/56] chore: refresh client catalog and module graph for connection inject --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 6 ++++-- docs/module-graph.zh.md | 6 ++++-- .../cordis-client-runner/src/client/slot-catalog.ts | 8 ++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 144729d0c9..1263940a9c 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 54aa13217a01ed44b44925365526438d4c867928 -module-graph.zh.md: 33c2f53afeb94c6d844f8406c1043d780436f588 +module-graph.md: a7a9617121370b943782abeb03695e360460acc2 +module-graph.zh.md: 46136554a990f92e47f80d57a2a12ad866d755db diff --git a/docs/module-graph.md b/docs/module-graph.md index 54aa13217a..a7a9617121 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1329,6 +1329,7 @@ flowchart TD pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_client_connection pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1352,6 +1353,7 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_ui_workspace --> pkg_client_connection pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_conversation @@ -1621,11 +1623,11 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 33c2f53afe..46136554a9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1331,6 +1331,7 @@ flowchart TD pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_client_connection pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1354,6 +1355,7 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_ui_workspace --> pkg_client_connection pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_conversation @@ -1623,11 +1625,11 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4eba7c202b..6fb951da48 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -536,7 +536,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:56', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:57', }, { key: 'conversation.input.attachments', @@ -1678,7 +1678,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:58', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:59', }, { key: 'tool.call.toolview', @@ -1695,7 +1695,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Open a Tool argument path through the Host. */\n openFile: (path: string) => void\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', + '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /** Open a Tool argument path through the Host. */\n openFile: (path: string) => void\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', ], ownerPropsReferences: [ 'Wire', @@ -1732,7 +1732,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:23', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:24', }, { key: 'tool.view.cordis', From f15ca233868aefe805fa900a953f3175caf7b27f Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 20:14:40 +0800 Subject: [PATCH 43/56] fix: ci --- .../2026-08-18-web-home-path-tilde.i18n.yaml | 4 ++-- .../feature/2026-08-18-web-home-path-tilde.md | 4 ++-- .../2026-08-18-web-home-path-tilde.zh.md | 4 ++-- .../ui-tool/tests/read-card.client.spec.tsx | 18 ++++++++++++++-- .../tests/tool-call-tree.client.spec.tsx | 12 ++++++++++- .../tests/tool-details-render.client.tsx | 9 ++++++-- .../ui-tool/tests/tool-row.client.spec.tsx | 8 ------- .../ui-workspace/src/client/contract/slots.ts | 17 +++++---------- .../tests/workspace-browser.client.spec.tsx | 21 +++++++++++++++++++ 9 files changed, 66 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml index e6983cb11f..0c4a921c34 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md -2026-08-18-web-home-path-tilde.md: 4b9b24454bbeeb394480c0c30470b7383a257790 -2026-08-18-web-home-path-tilde.zh.md: d901caab361755de44f6384d1016faf125175822 +2026-08-18-web-home-path-tilde.md: b148833bab09eadce4c9c1a362dd99d04eba5977 +2026-08-18-web-home-path-tilde.zh.md: 9d15cd6dca1128927389d5731dff6bf831cffe76 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md index 4b9b24454b..b148833bab 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md @@ -14,7 +14,7 @@ Workspace hover cards and Tool call summaries showed full POSIX home paths. Thos `abbreviateHomePath` in `dsh-client-runtime` is the display-only helper. It returns `~` or `~/…` when the path is the POSIX home or a descendant, and leaves the path unchanged when `home` is missing, empty, or `/`, when either value is a Windows drive or UNC path, or when the match is only a prefix (`/Users/u` does not claim `/Users/u2`). Tool summaries run workspace-relative shortening first, then this helper, so a path inside the session cwd stays short. `filePath`, Host open, and Workspace hover copy keep the authored filesystem path. -`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. A missing `hostDescription` on an incomplete test fake falls back to an absent source, so abbreviation does not run. +`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. The field is required on `ConnectionHandle`; test fakes supply a source whose snapshot may be undefined before connect. The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/fixture/Documents/project` lets assembled replay hover `~/Documents/project` without moving the existing `/tmp/fixture` account. TerminalBlock's own prompt-label collapse is unchanged. @@ -30,7 +30,7 @@ The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/f ## Consequences -POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Incomplete test connection fakes without `hostDescription` render unabbreviated paths instead of hanging or throwing. +POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Before the first describe, or while reconnecting, the source snapshot is undefined and paths stay unabbreviated. ## Testing diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md index d901caab36..9d15cd6dca 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md @@ -14,7 +14,7 @@ Workspace 悬停卡片和 Tool 调用摘要会显示完整的 POSIX 家目录路 `dsh-client-runtime` 中的 `abbreviateHomePath` 是仅用于展示的辅助函数。当路径是 POSIX 家目录或其后代时返回 `~` 或 `~/…`;`home` 缺失、为空或为 `/`,任一侧是 Windows 盘符或 UNC 路径,或只是前缀命中(`/Users/u` 不能收走 `/Users/u2`)时,路径保持不变。Tool 摘要先做工作区相对缩短,再调用该辅助函数,因此会话 cwd 内的路径仍然更短。`filePath`、Host 打开以及 Workspace 悬停复制仍使用作者给出的文件系统路径。 -`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。测试假对象若缺少 `hostDescription`,会回退到空来源,因此不会进行缩写。 +`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。该字段在 `ConnectionHandle` 上是必填的;测试假对象提供一个来源,其快照在连接完成前可以为 undefined。 fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位于 `/home/fixture/Documents/project`,组装回放可以悬停出 `~/Documents/project`,而不必移动现有的 `/tmp/fixture` 账户。TerminalBlock 自有的提示符标签折叠保持不变。 @@ -30,7 +30,7 @@ fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位 ## Consequences -POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。缺少 `hostDescription` 的不完整测试连接假对象会渲染未缩写路径,而不是挂起或抛错。 +POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。首次 describe 之前或重连期间,来源快照为 undefined,路径保持未缩写。 ## Testing diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index a6001d0d8f..8ae0b21cfc 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -261,7 +261,12 @@ describe('ReadRow keyed toolview', () => { }) describe('DetailsPanel Output section (read)', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + function mount( + snapshot: ConversationSnapshot, + selection: SelectionTarget | null, + cwd?: string, + description?: Parameters[1], + ) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -282,7 +287,7 @@ describe('DetailsPanel Output section (read)', () => { return render( snapshot, subscribe: () => () => {} })} @@ -342,6 +347,15 @@ describe('DetailsPanel Output section (read)', () => { expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result') }) + it('abbreviates a leftover POSIX home path on the read card label', () => { + const view = mount(snapshot({ + nodes: [settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) })], + }), target, '/tmp/ws', { + version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false, + }) + expect(view.getByText('~/notes.md')).toBeTruthy() + }) + it('a running read keeps the 运行中… placeholder (no result view)', () => { const view = mount(snapshot({ runningCalls: [running()] }), target) expect(view.getByText('运行中…')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 441052556d..7f18800303 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -2,6 +2,7 @@ /** ToolCallTree-owned root/subcall markers and selection projection. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' +import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -21,6 +22,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ( function props( block: ToolResultNode, selectedCallId?: string, + description?: HostDescription, ): ToolTreeProps { const snapshot = {} as ConversationSnapshot const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession'] @@ -44,7 +46,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useHostDescription: (selector => selector(undefined)) as ToolTreeProps['useHostDescription'], + useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'], t, } as unknown as ToolTreeProps } @@ -79,4 +81,12 @@ describe('ToolCallTree', () => { expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true') expect(nests).toHaveLength(2) }) + + it('abbreviates a POSIX home path in the generic tool summary', () => { + const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' }) + const view = render() + expect(view.getByText('~/docs/a.ts')).toBeTruthy() + }) }) diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index 7d3ffefe7c..c0332e9ac6 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,4 +1,5 @@ /** Test adapter for the production conversation.details.tool registration. */ +import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' import type { ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId, } from '@deepseek-ai/dsh-client-runtime/client' @@ -51,9 +52,13 @@ export function toolChatSnapshot( /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. + * @param description - optional Host description so the details card can abbreviate home paths. * @returns a direct-test renderSlot implementation. */ -export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotProps['renderSlot'] { +export function renderToolDetails( + t: TranslateNS<'conversation'>, + description?: HostDescription, +): DetailsSlotProps['renderSlot'] { return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; // recover the concrete owner selected by the adapter's fixed slot. @@ -61,7 +66,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr return selector(undefined)} + useHostDescription={selector => selector(description)} t={t} /> } diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index bedea5d720..f1a78401e2 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -5,7 +5,6 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { resolveWorkspacePath } from '@deepseek-ai/dsh-client-runtime/client' import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' @@ -120,13 +119,6 @@ describe('tool-call-model', () => { expect(toolRowModel('bash', running()).filePath).toBeUndefined() }) - it('resolveWorkspacePath joins relative paths under cwd and passes absolute through', () => { - expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') - expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') - expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') - expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') - }) - it('displays workspace-rooted paths relative to the session cwd', () => { const cwd = '/Users/u/ws/' expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts') diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 3714a4fb98..bdd85052a0 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,8 +22,8 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -import type { HostDescription, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' -import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -80,17 +80,14 @@ export type DirectoryPickingInjected = { } /** Component-side view of the picking share: the bound occupancy selector hook. */ -export type DirectoryPickingHooks = { - /** Selector hook over this surface's directory-flow occupancy. */ - useDirectoryFlow: SnapshotSelectorHook -} +export type DirectoryPickingHooks = PropsHooks /** * Browser-private injected share (arrives via the register inject factory). * Data reads use the global framework hooks; these are the Host actions the * browsing region drives. */ -export type WorkspaceBrowserInjected = DirectoryPickingInjected & { +export type WorkspaceBrowserInjected = { hooks: DirectoryPickingInjected['hooks'] & { /** Current generation's Host description, bound by the slot renderer. */ hostDescription: HostDescriptionSource @@ -148,11 +145,7 @@ export type WorkspaceBrowserProps = & PropsRenderSlots<'sidebar.workspaces.directoryFlow'> & PropsStore> & Omit - & DirectoryPickingHooks - & { - /** Selector hook over the current generation's Host description. */ - useHostDescription: SnapshotSelectorHook - } + & PropsHooks & PropsLocale<'workspace'> /** diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 7b59ffd9ea..a8cfad6a10 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -96,6 +96,27 @@ function rerender(b: ReturnType, overrides: Partial { + it('workspace hover card shows a POSIX home descendant as ~', () => { + vi.useFakeTimers() + try { + mount({ + useWorkspaces: hook(workspaceState([{ + ...workspace('project', []), + path: '/home/u/Documents/project', + title: 'Project', + }])), + useHostDescription: selector => selector({ + version: '0', cwd: '/tmp', attachedSessions: 0, home: '/home/u', canOpenPath: false, + }), + }) + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('~/Documents/project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => { const pending = { ...workspaceState([]), From bf2e9e474cfb55dd7c7c2106c8a7d07b0e4cb1f4 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 21:17:41 +0800 Subject: [PATCH 44/56] refactor(subagent): simplify Claude cleanup ownership --- .../subagent/subagent-claude-code/src/run.ts | 26 ++++++------- .../tests/real-product.spec.ts | 3 -- .../tests/subagent-claude-code.spec.ts | 37 ++++++++++--------- 3 files changed, 30 insertions(+), 36 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 47bdca84d9..97ecc26339 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -262,7 +262,8 @@ export async function consumeClaudeQuery( * Close the official query, terminate the managed process tree, and wait for * the subprocess owner to prove it is gone. * @param query - official SDK query, when creation reached that point. - * @param child - shared-service handle that owns the CLI process tree. + * @param child - live shared-service handle that owns the CLI process tree; + * spawn-failed handles settle at the startup boundary instead. */ export async function disposeClaudeCodeChild( query: Pick | undefined, @@ -276,13 +277,11 @@ export async function disposeClaudeCodeChild( failures.push(thrown(error)) } - if (child.pid > 0) { - child.terminate() - try { - await child.waitForExit() - } catch (error: unknown) { - failures.push(thrown(error)) - } + child.terminate() + try { + await child.waitForExit() + } catch (error: unknown) { + failures.push(thrown(error)) } try { outcome = await child.done @@ -297,13 +296,10 @@ export async function disposeClaudeCodeChild( category: 'unknown', outcome, } as const - if (failures.length === 1) { - throw new ClaudeCodeFailure(facts, firstFailure) - } - throw new AggregateError( - failures.map(failure => new ClaudeCodeFailure(facts, failure)), - `subagent-claude-code: ${failureDiagnostic(facts)}`, - ) + const cause = failures.length === 1 + ? firstFailure + : new AggregateError(failures, 'Claude Code teardown failures') + throw new ClaudeCodeFailure(facts, cause) } } diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 4e8310958c..0141fb7311 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -322,9 +322,6 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 .toBe(process.platform === 'win32' ? realpathSync(claudeBin).toLowerCase() : realpathSync(claudeBin)) - expect(harness.spawnSpecs[0]?.env) - .not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE') - expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! expect(recorded.method).toBe('POST') diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 68b9536b23..ac5734e897 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -451,8 +451,6 @@ describe('task admission and package contracts', () => { const safeChild = fakeChild() const bypassChild = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] - vi.spyOn(ctx.subprocess, 'resolveExecutable') - .mockResolvedValue('/native/claude') vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { spawnSpecs.push(spec) return spec.env?.DSH_CLAUDE_INSTANCE === 'safe' @@ -463,7 +461,6 @@ describe('task admission and package contracts', () => { queryMock.mockImplementation(({ options }) => { queryOptions.push(options) options.spawnClaudeCodeProcess!(sdkSpawnOptions({ - command: options.pathToClaudeCodeExecutable!, cwd: options.cwd!, env: options.env!, signal: options.abortController!.signal, @@ -770,7 +767,6 @@ describe('official spawn projection', () => { expect(spec.argv).toEqual([ command, '--output-format', 'stream-json', ]) - expect(spec.env).not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE') }) it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { @@ -1629,28 +1625,33 @@ describe('query and process disposal', () => { 'unknown', { exitCode: 0, signal: null }, )) - await expect(waitAndClose).rejects.toBeInstanceOf(AggregateError) + const waitAndCloseError = await waitAndClose.then( + () => undefined, + (error: unknown) => error, + ) + const waitAndCloseCause = errorCause(waitAndCloseError) + expect(waitAndCloseCause).toBeInstanceOf(AggregateError) + expect((waitAndCloseCause as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'close boom' }), + expect.objectContaining({ message: 'wait boom' }), + ]) expect(waitFailure.terminate).toHaveBeenCalledOnce() const doneFailure = fakeChild({ - pid: -1, doneError: new Error('spawn boom'), }) - await expect(disposeClaudeCodeChild( + const directChildFailure = disposeClaudeCodeChild( { close: vi.fn() }, doneFailure.handle, - )).rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) - - const both = fakeChild({ - pid: -1, - doneError: new Error('spawn boom'), - }) - const bothFailures = disposeClaudeCodeChild( - { close: () => { throw new Error('close boom') } }, - both.handle, ) - await expect(bothFailures) + await expect(directChildFailure) .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) - await expect(bothFailures).rejects.toBeInstanceOf(AggregateError) + await expect(directChildFailure).rejects.not.toThrow('spawn boom') + const directChildError = await directChildFailure.then( + () => undefined, + (error: unknown) => error, + ) + expect(errorCause(directChildError)?.message).toBe('spawn boom') + expect(doneFailure.terminate).toHaveBeenCalledOnce() }) }) From ec3da3809a9d222557c95a29a9806c733bd5f1b8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 21:29:32 +0800 Subject: [PATCH 45/56] refactor(subagent): align Claude teardown with live handles --- .../subagent/subagent-claude-code/src/run.ts | 7 +----- .../tests/subagent-claude-code.spec.ts | 23 +++---------------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 97ecc26339..3b0a19073d 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -270,7 +270,6 @@ export async function disposeClaudeCodeChild( child: SubprocessHandle, ): Promise { const failures: Error[] = [] - let outcome: SubprocessOutcome | undefined try { query?.close() } catch (error: unknown) { @@ -283,11 +282,7 @@ export async function disposeClaudeCodeChild( } catch (error: unknown) { failures.push(thrown(error)) } - try { - outcome = await child.done - } catch (error: unknown) { - failures.push(thrown(error)) - } + const outcome = await child.done const firstFailure = failures[0] if (firstFailure !== undefined) { diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index ac5734e897..16ced04d72 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1535,7 +1535,7 @@ describe('run publication, cancellation, and settlement', () => { const constructionError = new Error( 'query construction failed with a live child', ) - const liveChildCleanupFailure = fakeChild({ doneError: cleanupError }) + const liveChildCleanupFailure = fakeChild({ waitForExitError: cleanupError }) queryMock.mockImplementationOnce(({ options }) => { options.spawnClaudeCodeProcess!(sdkSpawnOptions()) throw constructionError @@ -1545,7 +1545,7 @@ describe('run publication, cancellation, and settlement', () => { spawn: () => liveChildCleanupFailure.handle, }) await expect(liveCleanupFailure).rejects.toMatchObject({ - message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown', { exitCode: 0, signal: null })}`, errors: [ expect.objectContaining({ cause: constructionError }), expect.objectContaining({ cause: cleanupError }), @@ -1611,7 +1611,7 @@ describe('query and process disposal', () => { expect(disposed).toBe(true) }) - it('reports wait, close, and direct-child failures without skipping cleanup', async () => { + it('reports close and tree-wait failures without skipping cleanup', async () => { const waitFailure = fakeChild({ waitForExitError: new Error('wait boom'), }) @@ -1636,22 +1636,5 @@ describe('query and process disposal', () => { expect.objectContaining({ message: 'wait boom' }), ]) expect(waitFailure.terminate).toHaveBeenCalledOnce() - - const doneFailure = fakeChild({ - doneError: new Error('spawn boom'), - }) - const directChildFailure = disposeClaudeCodeChild( - { close: vi.fn() }, - doneFailure.handle, - ) - await expect(directChildFailure) - .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) - await expect(directChildFailure).rejects.not.toThrow('spawn boom') - const directChildError = await directChildFailure.then( - () => undefined, - (error: unknown) => error, - ) - expect(errorCause(directChildError)?.message).toBe('spawn boom') - expect(doneFailure.terminate).toHaveBeenCalledOnce() }) }) From c3a04de1cfbdc3649025d6ecbccad53af71826ef Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 21:41:06 +0800 Subject: [PATCH 46/56] test(subagent): model Claude spawn errors with failed handles --- .../subagent-claude-code/tests/subagent-claude-code.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 16ced04d72..cdcae485b2 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -802,7 +802,7 @@ describe('official spawn projection', () => { }) it('emits spawn errors', async () => { - const child = fakeChild() + const child = fakeChild({ pid: -1 }) const process = new ManagedClaudeCodeProcess(child.handle) const errorListener = vi.fn() const removed = vi.fn() From 1b9fd9eaa74eefa805480975cbac406d83ac2a51 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 22:41:32 +0800 Subject: [PATCH 47/56] test(subagent): trim redundant product evidence --- ...ude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...-04-claude-code-and-codex-subagent-backends.md | 8 ++++---- ...-claude-code-and-codex-subagent-backends.zh.md | 8 ++++---- .../tests/subagent-claude-code.spec.ts | 1 - .../subagent-codex/tests/subagent-codex.spec.ts | 15 +-------------- 5 files changed, 11 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 388c707cda..3593c3ceb1 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 89b605f88b40aea1cef8f0fb0a587ec70091d219 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 92955512da9bf2deda5ad20fe19f292cfab430dc +2026-08-04-claude-code-and-codex-subagent-backends.md: 9b47fcf49d47d2c3561245fa1e16ff8c5da0a35c +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: fcb1aac71be2da9d907ad67867c763e3051baec5 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 89b605f88b..9b47fcf49d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -38,7 +38,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The Provider preserves every Codex 0.147.0 string and object `codexErrorInfo` variant in the shared safe diagnostic, including numeric HTTP status on the four connection/stream variants while omitting `turnKind`. It derives `initialize`, `thread-start`, `turn-start`, `turn`, `process`, and `teardown` from the current operation, preserves available exit code and signal, and uses `unknown` outside the fixed schema. `contextWindowExceeded` remains `max-tokens`; every other category remains `error`, `cyberPolicy` does not become `refusal`, and local cancellation remains `aborted` without a failure diagnostic. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns Codex error-info categories, HTTP status, lifecycle stages, process outcomes, and stop-reason preservation. Local cancellation remains `aborted` without a failure diagnostic. For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. @@ -52,7 +52,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp The public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each named instance retains those resolved values for its own runs. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. -The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every other result remains `error`, but its bounded diagnostic preserves the four exact SDK error subtypes, fixed categories for invalid success and missing result, a safe `unknown` fallback, the current `query-start`, `query-run`, `process`, or `teardown` stage, and any observed exit code and signal. A contributing permission decision follows that structured failure line. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without either diagnostic fact. +The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns every non-success category, stage, process outcome, and its ordering with a contributing permission decision. Local cancellation wins and becomes `aborted` without either diagnostic fact. Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. @@ -62,11 +62,11 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Generated schema evidence and package tests pin all sixteen error-info variants, HTTP status, six stages, process outcomes, stop-reason mapping, unknown fallback, sanitization, permission ordering, cancellation, concurrency, and cleanup aggregation. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, a real `internalServerError`, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, process/protocol failure, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, while Provider tests keep the public startup error on fixed safe facts. Two named instances retain separate environments and native modes. Production never resolves a host `codex` from `PATH`. +The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, a real `error_max_turns` result, a process exit with its outcome, safe permission diagnostics, local cancellation, and whole-tree exit. Package tests pin the complete SDK error union, all four stages, unknown fallback, independent code and signal fields, sanitization, success and cancellation omission, and concurrent-run isolation. They also prove that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and reports a missing payload as safe `query-start` / `unknown` facts while retaining the native SDK error only on the internal cause chain and Host log. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader coverage resolves both products through their optional Bundle patches while starting neither product. +The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 92955512da..fcb1aac71b 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -38,7 +38,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。提供方会把 Codex 0.147.0 的每种字符串与对象 `codexErrorInfo` variant 保留进共享安全诊断;四种连接/stream variant 会保留数值 HTTP status,但不会公开 `turnKind`。它会从当前操作派生 `initialize`、`thread-start`、`turn-start`、`turn`、`process` 和 `teardown`,保留可用的退出码与信号,并对固定 schema 之外的值使用 `unknown`。`contextWindowExceeded` 仍是 `max-tokens`;其他类别仍是 `error`,`cyberPolicy` 不会成为 `refusal`,本地取消仍是 `aborted` 且不附带失败诊断。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 Codex error-info 类别、HTTP status、生命周期阶段、进程结果与终止原因保持。本地取消仍是 `aborted` 且不附带失败诊断。 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 @@ -52,7 +52,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 -只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。其他所有结果仍成为 `error`,但其有界诊断会保留四种准确 SDK 错误子类型、标记为错误的成功消息与结果缺失所对应的固定类别、安全的 `unknown` 回退、当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,以及已观测到的退出码和信号。若权限决定也参与失败,它会跟在结构化失败行之后。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。 +只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责所有非成功类别、阶段、进程结果,以及它们与参与失败的权限决定之间的顺序。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。 启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 @@ -62,11 +62,11 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。生成的 schema 证据与包测试会固定全部十六种 error-info variant、HTTP status、六个阶段、进程结果、终止原因映射、unknown 回退、脱敏、权限顺序、取消、并发与清理聚合。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、真实 `internalServerError`、测试拥有临时存储中的显式危险绕过写入、本地取消、进程/协议失败以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,而提供方测试会让公开启动错误保持固定安全事实。两个命名实例会保留彼此独立的环境与原生模式。生产环境不会从 `PATH` 解析宿主 `codex`。 +Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 schema、失败、进程结果与最终呈现证据。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、真实的 `error_max_turns` 结果、携带进程结果的提前退出、安全权限诊断、本地取消和整棵进程树退出。包测试会固定完整 SDK 错误联合、四个阶段、unknown 回退、相互独立的退出码与信号字段、脱敏、成功与取消时省略诊断,以及并发运行隔离;还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖、直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并在载荷缺失时公开安全的 `query-start` / `unknown` 事实,同时只在内部 cause 链与 Host 日志中保留原生 SDK 错误。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 +Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index cdcae485b2..cc37910a8e 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1182,7 +1182,6 @@ describe('run publication, cancellation, and settlement', () => { const outcomes: SubprocessOutcome[] = [ { exitCode: 23, signal: null }, { exitCode: null, signal: 'SIGABRT' }, - { exitCode: 23, signal: 'SIGABRT' }, { exitCode: null, signal: null }, ] for (const outcome of outcomes) { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 222b29c26f..e9d1d6f4b7 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -415,11 +415,6 @@ describe('task admission and package contracts', () => { expect(JSON.stringify(rows)).not.toContain('tool-subagent') }) - it('uses only the official package-declared wrapper for app-server', () => { - expect(codexAppServerArgv()[0]).toBe(process.execPath) - expect(codexAppServerArgv().slice(2)).toEqual(['app-server', '--stdio']) - }) - it('accepts one or more text blocks and rejects empty or non-text tasks', () => { expect(textTask([ { type: 'text', text: 'one' }, @@ -1666,7 +1661,6 @@ describe('run lifecycle and quiescence', () => { const outcomes: SubprocessOutcome[] = [ { exitCode: 9, signal: null }, { exitCode: null, signal: 'SIGABRT' }, - { exitCode: 9, signal: 'SIGABRT' }, { exitCode: null, signal: null }, ] for (const outcome of outcomes) { @@ -1689,7 +1683,7 @@ describe('run lifecycle and quiescence', () => { await run.dispose().catch(() => {}) } { - const outcome = { exitCode: 17, signal: 'SIGABRT' } as const + const outcome = { exitCode: 17, signal: null } as const const child = fakeChild({ exitOnTerminate: false }) const { run, turnStart } = await publishRun(child, undefined, { disposeGraceMs: 100, @@ -2229,13 +2223,6 @@ describe('disposeCodexChild', () => { expect(child.waitForExit).not.toHaveBeenCalled() }) - it('accepts absent stdin', async () => { - const child = fakeChild() - const handle = { ...child.handle, stdin: undefined } - const wire = defaultWire(child) - await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() - }) - it('reports tree-wait failure with safe teardown facts', async () => { const child = fakeChild({ waitForExitError: new Error('SECRET_TOKEN wait failure'), From cede4fdb76cadb6c4adee3c5985ad01d43333372 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 22:48:24 +0800 Subject: [PATCH 48/56] test(subagent): use valid process outcomes --- .../tests/subagent-claude-code.spec.ts | 4 ++-- .../subagent/subagent-codex/tests/subagent-codex.spec.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index cc37910a8e..3298e07037 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1396,7 +1396,7 @@ describe('run publication, cancellation, and settlement', () => { queryMock.mockImplementationOnce(({ options }) => { factoryController = options.abortController options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - spawned.settle({ exitCode: 17, signal: 'SIGABRT' }) + spawned.settle({ exitCode: 17, signal: null }) throw new Error('query construction failed') }) const factoryFailure = startClaudeCodeRun(request(), { @@ -1409,7 +1409,7 @@ describe('run publication, cancellation, and settlement', () => { await expect(factoryFailure).rejects.toThrow(expectedFailureDiagnostic( 'query-start', 'unknown', - { exitCode: 17, signal: 'SIGABRT' }, + { exitCode: 17, signal: null }, )) await expect(factoryFailure).rejects.not.toThrow('query construction failed') expect(spawnSpecs).toHaveLength(1) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index e9d1d6f4b7..150c3ee4d0 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1708,7 +1708,7 @@ describe('run lifecycle and quiescence', () => { codexErrorInfo: 'other', })) child.fromChild.end() - child.settle({ exitCode: 17, signal: 'SIGABRT' }) + child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [], diagnostic: expectedFailureDiagnostic('turn', 'other'), @@ -1725,7 +1725,7 @@ describe('run lifecycle and quiescence', () => { turnCompleted('completed'), ) child.fromChild.end() - child.settle({ exitCode: 17, signal: 'SIGABRT' }) + child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed', @@ -1967,11 +1967,11 @@ describe('run lifecycle and quiescence', () => { }) await exitedThreadChild.peer.nextMethod('initialized') await exitedThreadChild.peer.nextMethod('thread/start') - exitedThreadChild.settle({ exitCode: 17, signal: 'SIGABRT' }) + exitedThreadChild.settle({ exitCode: null, signal: 'SIGABRT' }) await expect(exitedThreadStarting).rejects.toThrow(expectedFailureDiagnostic( 'thread-start', 'unknown', - { outcome: { exitCode: 17, signal: 'SIGABRT' } }, + { outcome: { exitCode: null, signal: 'SIGABRT' } }, )) const stderrChild = fakeChild() From 7fb9de99b5bace643816888f5af6736da8c15187 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 23:06:14 +0800 Subject: [PATCH 49/56] fix(subagent): close Codex failure result gaps --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/subagent/subagent-codex/src/index.ts | 18 +++++- packages/subagent/subagent-codex/src/run.ts | 17 ++++- .../tests/subagent-codex.spec.ts | 63 ++++++++++++++++++- 6 files changed, 96 insertions(+), 10 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8cd5f2a748..bbb667e729 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 127e9bebe677f9d122699d02dedca35002b047af -config-catalog.zh.md: 547116ef2c279d43c9d07f2c0d59f1e2d8e1abe5 +config-catalog.md: 1a1e376a0f3f54fd602510cfa70ad067abd385fa +config-catalog.zh.md: 46a08e13283c8b994f42597c3551478aa40717e7 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 127e9bebe6..1a1e376a0f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2145,7 +2145,7 @@ export type CodexPermissionMode = | 'dangerously-bypass-approvals-and-sandbox' ``` -Source: [`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts) +Source: [`packages/subagent/subagent-codex/src/index.ts:36`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 547116ef2c..46a08e1328 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2147,7 +2147,7 @@ export type CodexPermissionMode = | 'dangerously-bypass-approvals-and-sandbox' ``` -来源:[`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts) +来源:[`packages/subagent/subagent-codex/src/index.ts:36`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index d94197edf7..79fbdab078 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -21,6 +21,7 @@ import { CODEX_PERMISSION_MODES, DEFAULT_CODEX_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, + codexStartupFailure, startCodexRun, type CodexPermissionMode, type CodexRunSpec, @@ -73,12 +74,23 @@ class CodexProvider implements SubagentProvider { 'subagent-codex: no working directory for the child — delegate from a parent session that has one', ) } - const spec: CodexRunSpec = { - cwd: resolveChildCwd( + let cwd: string + try { + cwd = resolveChildCwd( 'subagent-codex', undefined, parentCwd, - ), + ) + } catch (error: unknown) { + if (request.signal.aborted) { + throw new Error( + 'subagent-codex: request was aborted before app-server startup', + ) + } + throw codexStartupFailure(error) + } + const spec: CodexRunSpec = { + cwd, permissionMode: this.config.permissionMode, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index d8e6265e02..7f7b0aac97 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -113,6 +113,18 @@ class CodexRunFailure extends Error { } } +/** + * Hide an unpublished Host failure behind fixed safe startup facts. + * @param cause Original Host failure retained for internal diagnostics. + * @returns A startup failure whose message contains only fixed safe facts. + */ +export function codexStartupFailure(cause: unknown): Error { + return new CodexRunFailure({ + stage: 'initialize', + category: 'unknown', + }, cause) +} + /** * Fixed package-local app-server command, independent of the host `PATH`. * @returns Node, the official wrapper, and the fixed app-server arguments. @@ -346,6 +358,9 @@ export async function startCodexRun( try { const terminal = await wire.runTurn(texts, runAbort.signal) if (terminal.stopReason === 'completed') return terminal + // Let stderr already queued with the terminal frame contribute its + // fixed permission fact before the non-completed result is snapshotted. + await new Promise((resolve) => { setImmediate(resolve) }) const facts = wire.collectFailure() return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } } catch (error: unknown) { @@ -360,7 +375,7 @@ export async function startCodexRun( ) { try { const exited = await child.waitForExit( - AbortSignal.timeout(spec.disposeGraceMs), + AbortSignal.timeout(Math.ceil(spec.disposeGraceMs)), ) if (exited) await child.done } catch { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 150c3ee4d0..b4bfbf741c 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1656,6 +1656,26 @@ describe('run lifecycle and quiescence', () => { } }) + it('includes a queued stderr permission fact in a max-token result', async () => { + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + setImmediate(() => { + child.stderr.write('approval policy is Never; reject command') + }) + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'contextWindowExceeded', + }), + ) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial answer' }], + diagnostic: `${expectedFailureDiagnostic('turn', 'contextWindowExceeded')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, + stopReason: 'max-tokens', + }) + await run.dispose() + }) + it('flattens child exit and protocol failures after publication', async () => { const errors: string[] = [] const outcomes: SubprocessOutcome[] = [ @@ -1686,11 +1706,15 @@ describe('run lifecycle and quiescence', () => { const outcome = { exitCode: 17, signal: null } as const const child = fakeChild({ exitOnTerminate: false }) const { run, turnStart } = await publishRun(child, undefined, { - disposeGraceMs: 100, + disposeGraceMs: 0.5, }) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + vi.spyOn(child.handle, 'waitForExit').mockImplementationOnce(async (signal?: AbortSignal) => { + expect(signal).toBeDefined() + child.settle(outcome) + return true + }) child.fromChild.emit('end') - setTimeout(() => { child.settle(outcome) }, 5) await expect(run.result).resolves.toEqual({ output: [], diagnostic: expectedFailureDiagnostic('process', 'process-exit', { @@ -2117,6 +2141,41 @@ describe('run lifecycle and quiescence', () => { permissionMode: 'approve-for-me', disposeGraceMs: 25, }) + + const invalidCwdParent = { + id: 'parent-with-invalid-cwd', + session: { header: { cwd: 'relative/SECRET_TOKEN' } }, + } as unknown as Agent + const invalidCwdError: unknown = await ctx.subagents.start('codex-diagnostic', { + prompt: [{ type: 'text', text: 'task' }], + parent: invalidCwdParent, + signal: new AbortController().signal, + }).then( + () => undefined, + (error: unknown) => error, + ) + expect(invalidCwdError).toBeInstanceOf(Error) + if (!(invalidCwdError instanceof Error)) { + throw new Error('expected safe invalid-cwd failure') + } + expect(invalidCwdError.message).toContain( + expectedFailureDiagnostic('initialize', 'unknown'), + ) + expect(invalidCwdError.message).not.toContain('relative/SECRET_TOKEN') + expect(invalidCwdError.cause).toBeInstanceOf(Error) + expect((invalidCwdError.cause as Error).message) + .toContain('relative/SECRET_TOKEN') + expect(spawn).not.toHaveBeenCalled() + + const invalidCwdAbort = new AbortController() + invalidCwdAbort.abort(new Error('cancel invalid cwd startup')) + await expect(ctx.subagents.start('codex-diagnostic', { + prompt: [{ type: 'text', text: 'task' }], + parent: invalidCwdParent, + signal: invalidCwdAbort.signal, + })).rejects.toThrow('aborted before app-server startup') + expect(spawn).not.toHaveBeenCalled() + const starting = ctx.subagents.start('codex-diagnostic', { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent, From 8debb798d8c2d82d671183bbb07e05f2961f5291 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 23:59:43 +0800 Subject: [PATCH 50/56] fix(subagent): bound Codex process settlement --- packages/subagent/subagent-codex/src/run.ts | 43 +++++++++++++++---- .../tests/subagent-codex.spec.ts | 43 ++++++++++++++++++- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 7f7b0aac97..8d38d2daba 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -189,6 +189,12 @@ export async function disposeCodexChild( wire.close() if (child.pid > 0) { + let outcome: SubprocessOutcome | undefined + void child.done.then( + (value) => { outcome = value }, + /* v8 ignore next -- a positive pid excludes spawn-level done rejection. */ + () => {}, + ) try { child.stdin?.end() } catch { @@ -198,7 +204,6 @@ export async function disposeCodexChild( try { await child.waitForExit() } catch (error: unknown) { - const outcome = await child.done throw new CodexRunFailure({ stage: 'teardown', category: 'unknown', @@ -294,8 +299,8 @@ export async function startCodexRun( throw new CodexRunFailure(processFailureFacts, thrown(error)) }, ) - // A normal post-result dispose also closes the process. Keep that expected - // late rejection observed after the result race has already settled. + // A normal post-result dispose also closes the process. Keep its expected + // late rejection observed when the terminal result settles first. processFailure.catch(() => {}) const runAbort = new AbortController() @@ -316,12 +321,17 @@ export async function startCodexRun( } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = runAbort.signal.aborted + if (!(error instanceof CodexRunFailure) && !cancelledBeforeCleanup) { + // Node reports stdout EOF before the child close that owns its outcome. + // Let an already-exiting process publish those facts before rollback. + await new Promise((resolve) => { setImmediate(resolve) }) + } const failure = new CodexRunFailure({ stage: startupStage, category: 'unknown', outcome: error instanceof CodexRunFailure ? error.facts.outcome - : undefined, + : processFailureFacts?.outcome, }, thrown(error)) try { await disposeProcess() @@ -353,10 +363,21 @@ export async function startCodexRun( : `${failure}\n${permission}` return diagnostic } + const publishedProcessFailure = processFailure.catch( + async (error: unknown): Promise => { + // Frames already queued by the exiting app-server remain authoritative. + // One I/O turn lets them settle before process exit ends the run. + await new Promise((resolve) => { setImmediate(resolve) }) + throw error + }, + ) const result: Promise = settleRunResult({ attempt: async () => { try { - const terminal = await wire.runTurn(texts, runAbort.signal) + const terminal = await Promise.race([ + wire.runTurn(texts, runAbort.signal), + publishedProcessFailure, + ]) if (terminal.stopReason === 'completed') return terminal // Let stderr already queued with the terminal frame contribute its // fixed permission fact before the non-completed result is snapshotted. @@ -382,11 +403,15 @@ export async function startCodexRun( // The wire failure remains authoritative when exit observation fails. } } - const facts = endedBeforeTerminal && processFailureFacts !== undefined - ? processFailureFacts - : wire.collectFailure() + const facts = error instanceof CodexRunFailure + ? error.facts + : endedBeforeTerminal && processFailureFacts !== undefined + ? processFailureFacts + : wire.collectFailure() recordFailureDiagnostic(facts) - throw new CodexRunFailure(facts, thrown(error)) + throw error instanceof CodexRunFailure + ? error + : new CodexRunFailure(facts, thrown(error)) } }, collectOutput, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index b4bfbf741c..29fba86745 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1689,7 +1689,6 @@ describe('run lifecycle and quiescence', () => { onError: (error) => { errors.push(error.message) }, }) child.settle(outcome) - child.fromChild.emit('end') await expect(run.result).resolves.toEqual({ output: [], diagnostic: expectedFailureDiagnostic('process', 'process-exit', { @@ -1998,6 +1997,28 @@ describe('run lifecycle and quiescence', () => { { outcome: { exitCode: null, signal: 'SIGABRT' } }, )) + const eofBeforeCloseChild = fakeChild({ exitOnTerminate: false }) + const eofBeforeCloseStarting = startCodexRun( + request(), + runSpec(eofBeforeCloseChild), + ) + const eofBeforeCloseInitialize = await eofBeforeCloseChild.peer + .nextMethod('initialize') + eofBeforeCloseChild.peer.respond(eofBeforeCloseInitialize, { + userAgent: 'codex-cli 0.147.0', + }) + await eofBeforeCloseChild.peer.nextMethod('initialized') + await eofBeforeCloseChild.peer.nextMethod('thread/start') + eofBeforeCloseChild.fromChild.emit('end') + setImmediate(() => { + eofBeforeCloseChild.settle({ exitCode: 23, signal: null }) + }) + await expect(eofBeforeCloseStarting).rejects.toThrow( + expectedFailureDiagnostic('thread-start', 'unknown', { + outcome: { exitCode: 23, signal: null }, + }), + ) + const stderrChild = fakeChild() const stderrStarting = startCodexRun(request(), runSpec(stderrChild)) const stderrInitialize = await stderrChild.peer.nextMethod('initialize') @@ -2295,4 +2316,24 @@ describe('disposeCodexChild', () => { )) await expect(disposal).rejects.not.toThrow('SECRET_TOKEN') }) + + it('does not wait for a pending process outcome after tree observation fails', async () => { + const child = fakeChild({ + exitOnTerminate: false, + waitForExitError: new Error('SECRET_TOKEN wait failure'), + }) + const wire = defaultWire(child) + let disposalError: unknown + const disposal = disposeCodexChild(wire, child.handle).catch( + (error: unknown) => { disposalError = error }, + ) + await nextTask() + expect(disposalError).toBeInstanceOf(Error) + expect(String(disposalError)).toContain( + expectedFailureDiagnostic('teardown', 'unknown'), + ) + expect(String(disposalError)).not.toContain('SECRET_TOKEN') + child.settle() + await disposal + }) }) From 34d313693dc40599f5d059669e133a396d6f3944 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 00:29:40 +0800 Subject: [PATCH 51/56] test(subagent): pin terminal exit ordering --- .../subagent/subagent-codex/tests/subagent-codex.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 29fba86745..0d08103ae6 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1727,10 +1727,11 @@ describe('run lifecycle and quiescence', () => { const child = fakeChild({ exitOnTerminate: false }) const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { - codexErrorInfo: 'other', - })) - child.fromChild.end() + setImmediate(() => { + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'other', + })) + }) child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [], From 72884ec430e131581891f17d50625a62e79d3641 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 01:10:29 +0800 Subject: [PATCH 52/56] fix(subagent): merge terminal and process facts --- packages/subagent/subagent-codex/src/run.ts | 10 ++++++++-- .../tests/subagent-codex.spec.ts | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 8d38d2daba..9db5d7086c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -363,6 +363,12 @@ export async function startCodexRun( : `${failure}\n${permission}` return diagnostic } + const withProcessOutcome = (facts: CodexFailureFacts): CodexFailureFacts => { + const outcome = processFailureFacts?.outcome + return outcome === undefined + ? facts + : { ...facts, outcome } + } const publishedProcessFailure = processFailure.catch( async (error: unknown): Promise => { // Frames already queued by the exiting app-server remain authoritative. @@ -382,7 +388,7 @@ export async function startCodexRun( // Let stderr already queued with the terminal frame contribute its // fixed permission fact before the non-completed result is snapshotted. await new Promise((resolve) => { setImmediate(resolve) }) - const facts = wire.collectFailure() + const facts = withProcessOutcome(wire.collectFailure()) return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } } catch (error: unknown) { // Give stderr data already queued in Node one turn to reach the wire @@ -407,7 +413,7 @@ export async function startCodexRun( ? error.facts : endedBeforeTerminal && processFailureFacts !== undefined ? processFailureFacts - : wire.collectFailure() + : withProcessOutcome(wire.collectFailure()) recordFailureDiagnostic(facts) throw error instanceof CodexRunFailure ? error diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 0d08103ae6..296c8afa09 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1661,16 +1661,17 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) setImmediate(() => { child.stderr.write('approval policy is Never; reject command') + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'contextWindowExceeded', + }), + ) }) - child.peer.send( - agentMessage('partial answer', null), - turnCompleted('failed', 'turn-1', 'thread-1', { - codexErrorInfo: 'contextWindowExceeded', - }), - ) + child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'partial answer' }], - diagnostic: `${expectedFailureDiagnostic('turn', 'contextWindowExceeded')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, + diagnostic: `${expectedFailureDiagnostic('turn', 'contextWindowExceeded', { outcome: { exitCode: 17, signal: null } })}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, stopReason: 'max-tokens', }) await run.dispose() @@ -1735,7 +1736,9 @@ describe('run lifecycle and quiescence', () => { child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: expectedFailureDiagnostic('turn', 'other'), + diagnostic: expectedFailureDiagnostic('turn', 'other', { + outcome: { exitCode: 17, signal: null }, + }), stopReason: 'error', }) await run.dispose().catch(() => {}) From 2814a338bebf150d68bed6b315279ac51efb8a5b Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 19 Aug 2026 10:01:11 +0800 Subject: [PATCH 53/56] test(subprocess): restore host-exit readiness handshake --- .../subprocess-local/tests/fixtures/process-exit-host.ts | 2 ++ .../subprocess/subprocess-local/tests/process-exit.spec.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index 721c5134c3..e59289be09 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -13,6 +13,7 @@ if ((kind !== 'ordinary' && kind !== 'terminal') } const treeState = join(root, 'tree.json') +const ready = join(root, 'ready') const proceed = join(root, 'proceed') const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) @@ -57,6 +58,7 @@ const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unkn if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { throw new Error('managed tree published invalid process ids') } +await writeFile(ready, 'ready') await waitForFile(proceed) if (trigger === 'dispose') { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index cdfc4f4ae0..217338fa1e 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -107,6 +107,10 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { let treeGone = false try { state = await readTree(join(root, 'tree.json')) + await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { + interval: 10, + timeout: scenarioTimeoutMs, + }) if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) await writeFile(join(root, 'proceed'), 'proceed') const outcome = await child From 7078918b30ebf8c18430fde7eeaee8da5a1721e5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 19 Aug 2026 11:39:41 +0800 Subject: [PATCH 54/56] feat(llm-deepseek): support multimodal requests --- .agents/notes/archived/manifest.json | 3 + ...6-07-04-drop-image-content-block.i18n.yaml | 4 +- .../2026-07-04-drop-image-content-block.md | 1 + .../2026-07-04-drop-image-content-block.zh.md | 1 + ...6-06-11-content-block-vocabulary.i18n.yaml | 4 +- .../2026-06-11-content-block-vocabulary.md | 2 +- .../2026-06-11-content-block-vocabulary.zh.md | 2 +- ...8-18-request-image-payload-bound.i18n.yaml | 4 +- .../2026-08-18-request-image-payload-bound.md | 3 +- ...26-08-18-request-image-payload-bound.zh.md | 3 +- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 8 +- ...-image-input-and-durable-attachments.zh.md | 8 +- ...-19-direct-deepseek-vision-input.i18n.yaml | 6 + ...2026-08-19-direct-deepseek-vision-input.md | 34 +++ ...6-08-19-direct-deepseek-vision-input.zh.md | 34 +++ .../tests/onboarding-deepseek-config.e2e.ts | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- .../code-mode-image.cordis.snapshot.yml | 6 +- examples/acp-agent/image.cordis.snapshot.yml | 12 +- examples/acp-agent/image.cordis.yml | 3 +- examples/acp-agent/tests/acp.snapshot.ts | 100 +++++-- .../tests/fixtures/image-offload.cordis.yml | 33 +-- .../code-mode-read-image/session.jsonl | 8 +- .../system-prompt.expected.md | 2 +- .../inline-image-prompt/session.jsonl | 6 +- .../read-image-dimension/session.jsonl | 8 +- .../tests/snapshots/read-image/session.jsonl | 8 +- .../read-image/system-prompt.expected.md | 24 ++ .../src/client/ModelSelect.module.css | 31 ++- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 25 +- packages/llm/llm-deepseek/README.zh.md | 25 +- packages/llm/llm-deepseek/package.json | 2 + packages/llm/llm-deepseek/src/adapter.ts | 52 +++- packages/llm/llm-deepseek/src/index.ts | 35 ++- packages/llm/llm-deepseek/src/serialize.ts | 243 +++++++++++++++--- packages/llm/llm-deepseek/src/types.ts | 19 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 61 +++++ .../llm/llm-deepseek/tests/adapter.spec.ts | 141 +++++++++- .../llm-deepseek/tests/dynamic-config.spec.ts | 76 +++++- .../llm/llm-deepseek/tests/serialize.spec.ts | 184 ++++++++++++- packages/llm/llm-pi-ai/src/context.ts | 90 +------ packages/llm/llm-pi-ai/tests/context.spec.ts | 4 +- packages/llm/llm/src/content.ts | 78 ++++++ packages/llm/llm/tests/content.spec.ts | 76 ++++++ pnpm-lock.yaml | 3 + 49 files changed, 1251 insertions(+), 251 deletions(-) rename .agents/notes/{implemented => archived}/simplification/2026-07-04-drop-image-content-block.i18n.yaml (69%) rename .agents/notes/{implemented => archived}/simplification/2026-07-04-drop-image-content-block.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-07-04-drop-image-content-block.zh.md (99%) create mode 100644 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md create mode 100644 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md create mode 100644 examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md create mode 100644 packages/llm/llm/tests/content.spec.ts diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index f8786470c7..2c9391bc96 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -334,6 +334,9 @@ "simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml": "sha256:eef600eafd70a576b2ac16a74f5dd5010ee601376008a90be10f1da56d746cee", "simplification/2026-07-02-remove-stream-chunk-mirror.md": "sha256:0c22a896260c6eb8991cc8babd8172f7b6889fc41bb891d748f34650b3eee5ec", "simplification/2026-07-02-remove-stream-chunk-mirror.zh.md": "sha256:ae7a3c2450b16fdf2f8da9e1f83cd987bf387671ecc8ebb76d2e7541695e7ee1", + "simplification/2026-07-04-drop-image-content-block.i18n.yaml": "sha256:6af8bc85be1fc23b445b17ff96df69488afa82c7194fcb47beb7a6b7b5a1de6a", + "simplification/2026-07-04-drop-image-content-block.md": "sha256:31e619d09405044a4db4951084f8d12417868b8f255ca26a44af0a5a13d43e6b", + "simplification/2026-07-04-drop-image-content-block.zh.md": "sha256:8fa6558bbb014a38853796102099ffbd5d8151371d68d40b2bf262ad07f4c976", "simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml": "sha256:e4c992a27ae0e37e5ef663c2cddf55eefe20387fd6103bebf655834d8e75e9db", "simplification/2026-07-04-drop-inert-request-knobs.md": "sha256:8735c2b868a85b13235e0491a0fa7b9570dd090eef5170324fc5e93782687b67", "simplification/2026-07-04-drop-inert-request-knobs.zh.md": "sha256:78b243f5d580f2a6fbbdb7d26574295d6ed74feb8d9bba34bbcdf4aa87624b5c", diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml similarity index 69% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml index f51aaf86b4..f718de7c8a 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md -2026-07-04-drop-image-content-block.md: 5d0357270973ac20234d8ce72c403f27ce2ad0e0 -2026-07-04-drop-image-content-block.zh.md: a13133059cdf4f6e6e8fe44e313a9795a57c10c9 +2026-07-04-drop-image-content-block.md: 362afbbe7c5c1a3b46b1e8abda3b0fec77d1a0e6 +2026-07-04-drop-image-content-block.zh.md: 07fbbd1f165887bf3305ccfa7ba137656ce0a99c diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md index 5d03572709..362afbbe7c 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md @@ -1,6 +1,7 @@ # Agent Note: Drop the `image` content block until a path can honor it Status: implemented +Archived: 2026-08-19 English | [中文](2026-07-04-drop-image-content-block.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md index a13133059c..07fbbd1f16 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md @@ -1,6 +1,7 @@ # Agent Note: 移除 `image` 内容块,直到有路径能真正处理它 Status: implemented +Archived: 2026-08-19 [English](2026-07-04-drop-image-content-block.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 0b150d8090..0c0e629d26 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md -2026-06-11-content-block-vocabulary.md: 5228724bb9101307db9929aaf7831b477c2a6022 -2026-06-11-content-block-vocabulary.zh.md: b43de335917c6b5304a94b296fcb8253827b0600 +2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b +2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md index 5228724bb9..a31df6a7d1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -22,7 +22,7 @@ In-session context injection (`context/message`) and mid-turn steering originall ## Consequences - Reasoning has a core home without provider-specific shapes. -- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md). +- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md). - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index b43de33591..5ac882e9de 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 ## 后果 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 -- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。 +- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md)。 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 - 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml index f797c08086..892a732be0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md -2026-08-18-request-image-payload-bound.md: df10df39d18c7da4660b566e8f0b6a5a60ff8dc1 -2026-08-18-request-image-payload-bound.zh.md: 070f2d194f1459f3f2728fdf9d5d2db2c3a24385 +2026-08-18-request-image-payload-bound.md: 0ec4594888db6157fb8cfd3e7bdb231b842d53c1 +2026-08-18-request-image-payload-bound.zh.md: 17b3fbb09be1aff0036c1cec389d8dd75966e010 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md index df10df39d1..0ec4594888 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md @@ -10,7 +10,7 @@ Every image in session history is base64-inlined into every model request by the ## Decision -The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB request-image default therefore retains four such images and reserves the rest of a 32MiB request for system prompts, history, tools, and JSON. Deployments behind stricter gateways lower the value per route. +The pi-ai provider profile and direct DeepSeek adapter carry `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, changeable from cordis.yml and settings). The provider-neutral `offloadRequestImages` conversion sums the base64 length of every image in history from `ImageAttachmentRef.bytes` without reading data and, while the sum exceeds the bound, replaces the oldest image occurrences with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Occurrence-order replacement does not depend on object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. Both adapters classify 413 as `INVALID_REQUEST`; pi-ai also recognizes specific request-body-cap wording. Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB default therefore retains four such images and leaves headroom under the direct API's 30MiB request limit, while deployments behind stricter gateways lower the value per route. ## Offload is conversion, not history @@ -26,6 +26,7 @@ The placeholder is model-visible but not logged as a session event. It stays wit ## Related - [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size). +- [Direct DeepSeek vision input](../feature/2026-08-19-direct-deepseek-vision-input.md) — applies this provider-neutral conversion to the official multimodal route. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md index 070f2d194f..17b3fbb09b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md @@ -10,7 +10,7 @@ pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型 ## Decision -pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 请求图片默认上限因此可保留四张这样的图片,并在 32MiB 请求内为系统提示词、历史、工具与 JSON 保留其余容量。网关更严格的部署按路由调低该值。 +pi-ai provider profile 与直接 DeepSeek 适配器都提供 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,可从 cordis.yml 与 settings 修改)。提供方无关的 `offloadRequestImages` 转换由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片出现位置开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。按出现顺序替换不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。两个适配器都把 413 归类为 `INVALID_REQUEST`;pi-ai 还会识别明确的请求体上限措辞。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 默认上限因此可保留四张这样的图片,并在直接 API 的 30MiB 请求上限下留出余量;网关更严格的部署则按路由调低该值。 ## offload 是转换而非历史 @@ -26,6 +26,7 @@ pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUE ## Related - [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。 +- [直接 DeepSeek 视觉输入](../feature/2026-08-19-direct-deepseek-vision-input.md)把这项提供方无关转换应用于官方多模态路由。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 842f2c9cd3..73b81c1686 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a12380992c3205d6bb3f5701f093b3335fae2f9 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a8e94154e435a5359e7928d3b11eba7d1aee92f7 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 83293fbe9bd4e8a7b4422dbbe8495cf9f48bcdb5 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5c524da9cbed7b330c3823812c8a71aed7aa74b0 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 6a12380992..83293fbe9b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -8,7 +8,7 @@ English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationController.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. -This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. +This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. @@ -124,7 +124,7 @@ Model catalog entries gain optional merge-extensible input modality declarations The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast. -The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route accepts configured image-capable catalog entries but does not advertise a vision model until its endpoint is ready; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. @@ -152,7 +152,7 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc | `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | -| `packages/llm/llm-deepseek` | Reject image content explicitly. | +| `packages/llm/llm-deepseek` | Resolve declared official vision input and reject images for text-only models. | | `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | | `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. | | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | @@ -165,7 +165,7 @@ The attachment packages form the interface/implementation side of one capability ### Implementation -The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. +The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI and direct DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index a8e94154e4..5c524da9cb 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -8,7 +8,7 @@ Status: implemented 在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationController.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 -这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 +这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 [Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 @@ -124,7 +124,7 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由接受已配置且支持图片的 catalog 配置项,但在视觉模型端点就绪前不会公布视觉模型;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 @@ -152,7 +152,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme | `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | -| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | +| `packages/llm/llm-deepseek` | 解析已声明的官方视觉输入,并拒绝纯文本模型的图片。 | | `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | | `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 | | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | @@ -165,7 +165,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 实现 -已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 +已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 与直接 DeepSeek 输入转换、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml new file mode 100644 index 0000000000..a2caf36d87 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md +2026-08-19-direct-deepseek-vision-input.md: 60f55f10e108a29d465e388fa385b1955b7f2ce5 +2026-08-19-direct-deepseek-vision-input.zh.md: 7f41d89ffd13da1b1b170f114dfc481e66075ef2 diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md new file mode 100644 index 0000000000..60f55f10e1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md @@ -0,0 +1,34 @@ +# Agent Note: Direct DeepSeek vision input + +Status: implemented + +English | [中文](2026-08-19-direct-deepseek-vision-input.zh.md) + +## Problem + +DeepSeek vision deployments use the chat-completions image protocol, but the direct `deepseek-official` adapter declares every catalog and pass-through model text-only and rejects every `ImageBlock`. The durable attachment path therefore works only through configurable pi-ai routes, and a deployment cannot pass user uploads or image-bearing tool results through the direct provider. + +## Decision + +The direct adapter lets a configured model opt in with `inputModalities: [text, image]`; validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only. The shipped catalog does not advertise `deepseek-v4-flash-vision-exp` until its model endpoint is ready, so the model selector cannot offer an unavailable route; deployment and snapshot catalogs can enable their exact vision model independently. + +The adapter resolves `ctx.attachments` per image request, reads each retained durable reference with the request signal, and serializes verified bytes as ordered OpenAI-compatible `image_url` data URLs. Text-only user messages retain string content. Tool results retain string-only `tool` messages; image-only results use `(see attached image)`, and consecutive retained tool-result images follow in one `user` message beginning `Attached image(s) from tool result:`. System and assistant history images fail with `UNSUPPORTED_CONTENT` before attachment or network I/O. + +The direct adapter and pi-ai conversion share the deterministic [request-level image payload bound](../bug-fix/2026-08-18-request-image-payload-bound.md). Both default to 20 MiB of accumulated base64 payload, replace oldest image occurrences with the same fixed placeholder, and never read omitted attachments. Direct HTTP 413 responses are `INVALID_REQUEST`; attachment failures retain their stable attachment code rather than becoming `TRANSPORT`. + +Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist only while preparing one provider request, so no session event, persistence format, API schema, or SDK projection changes. The route accepts PNG, JPEG, WebP, and GIF already admitted by the attachment service. External image URLs, the Files API, and image output remain unsupported. + +## Alternatives considered + +- **Use only the pi-ai DeepSeek provider.** Its generic multimodal path proves the content conversion, but it does not make the direct official route truthful or usable with the official model id. +- **Declare the whole provider image-capable.** This would let Flash, Pro, and unknown pass-through ids accept durable images that their exact wire model cannot promise to consume. Capability remains exact-model metadata. +- **Send images inside `tool` message content.** The documented compatible form keeps tool content a string. A following user message avoids relying on an undocumented multimodal tool-role form while preserving call-result order. +- **Add external URLs or Files uploads.** Both require new canonical input, authorization, lifetime, cleanup, and replay decisions. Transient base64 uses the existing durable attachment contract without expanding those concerns. + +## Verification + +Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. A key-gated real-API e2e sends a deterministic image to the official vision model. + +## Consequences + +Configured DeepSeek vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available. diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md new file mode 100644 index 0000000000..7f41d89ffd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 直接 DeepSeek 视觉输入 + +Status: implemented + +[English](2026-08-19-direct-deepseek-vision-input.md) | 中文 + +## Problem + +DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-official` 适配器把所有 catalog 与原样传递模型都声明为仅文本,并拒绝每一个 `ImageBlock`。因此,持久附件路径只能经可配置 pi-ai 路由工作,部署方无法通过直接提供方传递用户上传或包含图片的工具结果。 + +## Decision + +直接适配器允许已配置模型通过 `inputModalities: [text, image]` 选择加入;校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。在模型端点就绪前,随附目录不会公布 `deepseek-v4-flash-vision-exp`,因此模型选择器不会提供不可用路由;部署与 snapshot 目录可以独立启用其确切视觉模型。 + +适配器会对每个图片请求解析 `ctx.attachments`,用请求 signal 读取每个保留的持久引用,并将校验后的字节按顺序序列化为 OpenAI 兼容的 `image_url` data URL。纯文本 user 消息保留字符串内容。工具结果保留仅字符串的 `tool` 消息;仅含图片的结果使用 `(see attached image)`,连续工具结果中保留的图片随后合并进一条以 `Attached image(s) from tool result:` 开头的 `user` 消息。System 与 assistant 历史图片会在附件或网络 I/O 前以 `UNSUPPORTED_CONTENT` 失败。 + +直接适配器与 pi-ai 转换共享确定性的[请求级图片载荷上限](../bug-fix/2026-08-18-request-image-payload-bound.md)。两者都以 20 MiB 累计 base64 payload 为默认值,用相同固定占位文本替换最旧的图片出现位置,并且绝不读取被省略的附件。直接 HTTP 413 响应归类为 `INVALID_REQUEST`;附件失败会保留其稳定附件 code,不会变成 `TRANSPORT`。 + +规范消息继续只存储 `ImageAttachmentRef`。Data URL 只在准备单次提供方请求时存在,因此无需修改会话事件、持久化格式、API schema 或 SDK 投影。路由接受已经由附件服务准入的 PNG、JPEG、WebP 和 GIF。不支持外部图片 URL、Files API 和图片输出。 + +## Alternatives considered + +- **只使用 pi-ai DeepSeek 提供方。** 其通用多模态路径验证了内容转换,但无法让直接官方路由如实公布能力,也无法让它配合官方模型 id 使用。 +- **把整个提供方声明为支持图片。** 这样会让 Flash、Pro 和未知的原样传递 id 接受持久图片,但其确切协议模型无法承诺消费这些图片。能力仍属于确切模型元数据。 +- **在 `tool` 消息内容中发送图片。** 已记录的兼容形式要求工具内容保持字符串。随后发送 user 消息可避免依赖未记录的多模态 tool role 形式,同时保留调用结果顺序。 +- **增加外部 URL 或 Files 上传。** 两者都需要新的规范输入、授权、生命周期、清理和重放决策。瞬态 base64 可以复用现有持久附件约定,不扩展这些问题。 + +## Verification + +包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。受密钥控制的真实 API e2e 会向官方视觉模型发送一张确定性图片。 + +## Consequences + +已配置的 DeepSeek 视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index ad9bc0903f..407a53dd22 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -224,7 +224,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(document).toContain('name: Private Preview') expect(document).toContain('contextWindow: 131072') expect(document).toContain('maxTokens: 64000') - expect(document).not.toContain('id: deepseek-v4-flash') + expect(document).not.toMatch(/^\s*- id: deepseek-v4-flash$/m) await page.keyboard.press('Escape') // A connected Workspace is what puts a live composer — and its model diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 6f7ac3d6b3..4c30b3c12a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: c03f1088745a5746a59b2fbddf60d7b270951371 -config-catalog.zh.md: 4c1f62f7c899dd6bf7c55c33e802859be9e79e5e +config-catalog.md: a8b85928addc86bcebbee1be4202abfffd3f5be5 +config-catalog.zh.md: 53034a3f6e4bdad0284b892e8a1031f43df469cf diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c03f108874..a8b85928ad 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -870,6 +870,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -886,12 +888,14 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } ``` -Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4c1f62f7c8..53034a3f6e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -872,6 +872,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -888,12 +890,14 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } ``` -依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:72`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/examples/acp-agent/code-mode-image.cordis.snapshot.yml b/examples/acp-agent/code-mode-image.cordis.snapshot.yml index f722a66efd..4f227ec19a 100644 --- a/examples/acp-agent/code-mode-image.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode-image.cordis.snapshot.yml @@ -13,7 +13,7 @@ name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek-official - model: deepseek-v4-flash + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -37,6 +37,8 @@ name: DeepSeek models: - id: deepseek-v4-flash - inputModalities: [text, image] + inputModalities: [text] - id: deepseek-v4-pro inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] diff --git a/examples/acp-agent/image.cordis.snapshot.yml b/examples/acp-agent/image.cordis.snapshot.yml index f056f775d3..7a355618d3 100644 --- a/examples/acp-agent/image.cordis.snapshot.yml +++ b/examples/acp-agent/image.cordis.snapshot.yml @@ -1,9 +1,9 @@ # Keyless replay for the read-image success scenario. Include patches cannot # target entries behind a nested include, so this restates the replay overlay # directly over the base cordis.yml (the fs.cordis.snapshot.yml pattern) and -# re-pins the recorded flash model. The replay catalog declares image input on -# flash, so the strict read_image gate accepts the route and the tool result -# carries the durable image block; the live DeepSeek route cannot record this. +# re-pins the recorded vision model. The replay catalog declares image input, +# so the strict read_image gate accepts the route and the tool result carries +# the durable image block. - id: base name: '@deepseek-ai/cordis-plugin-include' config: @@ -16,7 +16,7 @@ name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek-official - model: deepseek-v4-flash + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -36,6 +36,8 @@ name: DeepSeek models: - id: deepseek-v4-flash - inputModalities: [text, image] + inputModalities: [text] - id: deepseek-v4-pro inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] diff --git a/examples/acp-agent/image.cordis.yml b/examples/acp-agent/image.cordis.yml index a12cef1e85..3d60d107b4 100644 --- a/examples/acp-agent/image.cordis.yml +++ b/examples/acp-agent/image.cordis.yml @@ -3,7 +3,8 @@ # snapshot harness scopes per run, so the overlay itself carries no paths. The # app config is restated to re-pin `deepseek-v4-flash` (base ships pro; the # authored fixture and the pinned header class are flash), because a config -# patch replaces the whole app config. +# patch replaces the whole app config. Keyless replay supplies its own exact +# image-capable catalog while the production endpoint remains unavailable. - id: base name: '@deepseek-ai/cordis-plugin-include' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 4742906b8a..d32ef2abeb 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -16,6 +16,7 @@ import { } from '@deepseek-ai/dsh-acp-snapshot' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' +import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' /** * The acp-agent example's snapshot suite: the scenario table for @@ -222,27 +223,26 @@ const SCENARIOS: Scenario[] = [ posixOnly: true, }, // Authored keyless replays through the assembled app: the replay catalog - // declares flash image-capable (success) or text-only (refusal), and the + // declares the vision model image-capable and Flash text-only, and the // real read_image tool executes against the workspace fixture and the real - // attachment store. Both boot the same composed header (the tool registers - // with the attachment store, independent of route), so they share one class. + // attachment store. The success route selects the vision model while the + // refusal route retains text-only Flash, so each pins its exact header. { name: 'read-image', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'image', - // The overlay adds no prompt section (read_image carries no guidance), so - // the composed system prompt is byte-identical to the default class; only - // the tool-schema sidecar is class-specific. - systemPromptSource: 'text-turn', configPath: IMAGE_CONFIG, }, { name: 'read-image-text-route', hasModelTurn: true, recorded: false, - headerClass: 'image', + pinsHeader: true, + headerClass: 'image-text-route', + systemPromptSource: 'text-turn', + toolSchemasSource: 'read-image', configPath: IMAGE_TEXT_ROUTE_CONFIG, }, // Authored keyless replay of the oversized-image refusal: admission rejects @@ -688,7 +688,7 @@ defineAcpSnapshotSuite({ hasPwsh, }) -it('pins pi-ai image offload in the request sent by the assembled app', async () => { +it('pins native DeepSeek image offload in the request sent by the assembled app', async () => { const requests: Record[] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { let body = '' @@ -697,13 +697,21 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () request.on('end', () => { requests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end([ - 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + const events = requests.length === 1 + ? [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + : [ + 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + response.end(events.join('\n\n')) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -722,7 +730,7 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () { type: 'image', data: image, mimeType: 'image/png' }, { type: 'text', text: ' with the newer image ' }, { type: 'image', data: image, mimeType: 'image/png' }, - { type: 'text', text: ', then reply with DONE.' }, + { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, ], }, ], @@ -734,13 +742,14 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () mode: 'record', configPath: IMAGE_OFFLOAD_CONFIG, fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), + workspaceDir: join(SNAPSHOTS_DIR, 'read-image', 'workspace'), env: { DSH_SNAPSHOT_API_KEY: 'snapshot-key', - DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}/v1`, + DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, }) expect(result.stderr).toBe('') - expect(requests).toHaveLength(1) + expect(requests).toHaveLength(2) const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toMatchInlineSnapshot(` @@ -764,11 +773,62 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () "type": "image_url", }, { - "text": ", then reply with DONE.", + "text": ", then use read_image on red.png and reply with DONE.", "type": "text", }, ] `) + + const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{ + role?: unknown + content?: unknown + }> + const toolMessage = followup.find(message => message.role === 'tool') + if (toolMessage === undefined || typeof toolMessage.content !== 'string') { + throw new Error('native read_image request has no tool content') + } + const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => ( + cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`] + )))] + let toolContent = toolMessage.content + for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}') + toolMessage.content = toolContent + expect(followup).toEqual([ + { + role: 'user', + content: `Compare the older image ${OFFLOADED_IMAGE_TEXT} with the newer image ${OFFLOADED_IMAGE_TEXT}, then use read_image on red.png and reply with DONE.`, + }, + { + role: 'user', + content: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' + + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).', + }, + { + role: 'assistant', + content: '', + tool_calls: [{ + id: 'native-read-image', + type: 'function', + function: { name: 'read_image', arguments: '{"file_path":"red.png"}' }, + }], + }, + { + role: 'tool', + tool_call_id: 'native-read-image', + content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Attached image(s) from tool result:' }, + { + type: 'image_url', + image_url: { url: `data:image/png;base64,${image}` }, + }, + ], + }, + ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) } diff --git a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml index 6007c5d44d..530f7b9663 100644 --- a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml +++ b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml @@ -1,5 +1,5 @@ -# Keyless assembled-request snapshot for pi-ai image offload. The local -# provider endpoint is supplied by the snapshot test; the real attachment +# Keyless assembled-request snapshot for native DeepSeek image offload. The +# local provider endpoint is supplied by the snapshot test; the real attachment # store and ACP bridge carry two uploaded images into one model request. - id: base name: '@deepseek-ai/cordis-plugin-include' @@ -8,12 +8,21 @@ patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true + config: + apiKeyEnv: DSH_SNAPSHOT_API_KEY + baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL + thinking: disabled + maxRequestImageBytes: 92 + models: + - id: deepseek-v4-flash-vision-exp + contextWindow: 32768 + maxTokens: 1024 + inputModalities: [text, image] - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: snapshot - model: vision + provider: deepseek-official + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -25,17 +34,3 @@ - insert: - id: attachment-local name: '@deepseek-ai/dsh-attachment-local' - - id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - snapshot: - apiKeyEnv: DSH_SNAPSHOT_API_KEY - api: openai-completions - baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL - maxRequestImageBytes: 92 - models: - - id: vision - contextWindow: 32768 - maxTokens: 1024 - input: [text, image] diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl index cb63f29c34..e8990b8f69 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1786431644558,"data":{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1786431644558,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"99b9db8d-e4ec-4ea9-b5e2-1e4c0ff6354b"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786431644558,"data":{"title":"Using ONE run_code program, create","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1786431644571,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"644382c5-5a05-4bda-b8dc-b9195d6a7d8b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"ef352c42-b661-4b71-8c6a-7dbbd0a9f591"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786431644573,"data":{"turn":1,"step":1,"callId":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}} {"type":"tool/code-dispatch-start","seq":15,"time":1786431644697,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"}}} {"type":"tool/code-dispatch","seq":16,"time":1786431644828,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}} @@ -28,6 +28,6 @@ {"type":"assistant/chunk","seq":26,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":27,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":28,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a6da60ea-d420-432b-ba00-9b99af045110"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a721cef2-2c49-4336-8d07-5f6cc15f4b67"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"} {"type":"step/end","seq":30,"time":1786431644890,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":31,"time":1786431644890,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index e96ddb107c..786aa8fe80 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. +You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl index 89cffe656d..08d65c2c91 100644 --- a/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl +++ b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl @@ -6,12 +6,12 @@ {"type":"user/message","seq":4,"time":1783952000003,"data":{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1783952000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000002"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1783952000004,"data":{"title":"Inspect this image, then reply","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783952000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1783952000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":11,"time":1783952000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c0c0c0c-0000-4000-8000-000000000003"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"e58e49ab-9c34-4ba0-9276-9429b32c5ea0"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"step/end","seq":14,"time":1783952000010,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":15,"time":1783952000010,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl index 0b979e801f..7c74dd2d71 100644 --- a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1783951000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1783951000004,"data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a25d70ac-2bd6-4e44-9121-ed74975ee229"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1783951000009,"data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}} {"type":"tool/result","seq":15,"time":1783951000014,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1783951000014,"data":{"turn":1,"step":1}} @@ -21,6 +21,6 @@ {"type":"assistant/chunk","seq":19,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}} {"type":"assistant/chunk","seq":20,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":21,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"3a95dd83-34f7-4bc0-afb6-7ba3c9b483be"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1783951000018,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":24,"time":1783951000018,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image/session.jsonl b/examples/acp-agent/tests/snapshots/read-image/session.jsonl index 6acde5a0ff..72064a302d 100644 --- a/examples/acp-agent/tests/snapshots/read-image/session.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1786344283033,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786344283033,"data":{"title":"Use read_image to look at","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"41e9fb55-6edb-419d-b76c-554daa5a1c5d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"2b71c837-237d-4d92-a857-8b8ad1a3f237"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786344283039,"data":{"turn":1,"step":1,"callId":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} {"type":"tool/result","seq":15,"time":1786344283069,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"0b5779fc-523e-4275-9a32-8eb5e39f521e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1786344283069,"data":{"turn":1,"step":1}} @@ -21,6 +21,6 @@ {"type":"assistant/chunk","seq":19,"time":1786344283078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":20,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":21,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73a87a50-8e0b-42af-8c54-d9b6fbe375f1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"5a45946c-b9f4-4f2c-a7c3-2569e541ec1d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1786344283079,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":24,"time":1786344283079,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md new file mode 100644 index 0000000000..2d4ef255b8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md @@ -0,0 +1,24 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index b19242fb44..76b9d3cb2a 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -11,7 +11,10 @@ align-items: center; gap: 4px; min-width: 0; + /* Legacy fallback, then a row-relative cap that grants long names more + room without letting this trailing control consume the composer. */ max-width: 220px; + max-width: min(360px, 45cqw); height: 28px; padding: 0 4px 0 8px; border: none; @@ -69,7 +72,11 @@ z-index: 20; display: flex; flex-direction: column; - width: min(240px, calc(100vw - 32px)); + /* Let the visible pane's rows set the card width, but keep pathological ids + and narrow screens inside a bounded card where ellipsis remains. */ + width: max-content; + min-width: min(240px, calc(100vw - 32px)); + max-width: min(420px, calc(100vw - 32px)); max-height: min(360px, calc(100vh - 96px)); overflow: hidden; padding: 4px; @@ -150,10 +157,12 @@ } .option { + box-sizing: border-box; display: flex; align-items: center; gap: 8px; - width: 100%; + width: auto; + min-width: 100%; min-height: 38px; padding: 6px 8px; border: none; @@ -218,10 +227,12 @@ padding, 8px gap, 10px radius; 14/22 label in primary, value in the #81858C tertiary tone, right chevron drilling into the sub-list. */ .cell { + box-sizing: border-box; display: flex; align-items: center; gap: 8px; - width: 100%; + width: auto; + min-width: 100%; height: 40px; padding: 0 10px; border: none; @@ -239,19 +250,17 @@ } .cellLabel { + flex: 0 0 auto; + white-space: nowrap; +} + +.cellValue { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} - -.cellValue { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + text-align: right; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index cf54a96ec7..a049b4dd87 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 9bb28e6876b82c521341769123a8b2d0e5d98e09 -README.zh.md: 21c55cfa32bf68e0cac4c0bd72c94c86d955fc00 +README.md: 599dc1f530884df58fcc64d8cdf6c62fac80bc1f +README.zh.md: b1a45a1f398fdbb11cddebe34b646edf37f579be diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 9bb28e6876..599dc1f530 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -20,6 +20,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: @@ -30,12 +31,19 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek-V4-Flash + - id: private-vision + name: Private Vision + inputModalities: [text, image] - id: private-reasoner description: Company-hosted reasoning model contextWindow: 512000 ``` -The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Vision models are not advertised by default until their endpoint rollout is complete, but a deployment can add one with `inputModalities: [text, image]`. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. + +An image-capable catalog entry may declare `inputModalities: [text, image]`. The adapter resolves user and tool-result `ImageBlock` references through `ctx.attachments`, verifies the stored bytes, and sends transient `data:;base64,...` `image_url` parts without changing the durable session message. Text-only and unlisted models reject image input before credential, attachment, or network I/O. System and assistant history remain image-free; tool-result images follow their string-only `tool` messages in a separate `user` message. + +`maxRequestImageBytes` bounds accumulated base64 image payload and defaults to 20 MiB, leaving headroom below the official 30 MiB request-body limit for text, tools, and JSON framing. When history exceeds the bound, the oldest images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]` until the request fits; omitted attachments are not read. Attachment admission continues to own per-image and per-message raw-byte, media, dimension, and pixel limits. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -49,10 +57,11 @@ The same exact-model result exposes ordered `off`, `low`, `high`, and `max` effo ## Dynamic configuration (settings + credentials) -Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: +Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, image bound, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Three optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. - **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.attachments`** — image requests resolve this service at request time, so Cordis load order does not freeze optional image availability. Absence rejects image input with `UNSUPPORTED_CONTENT`; text-only calls do not require the service. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. @@ -70,11 +79,12 @@ DeepSeek request identity is separate from app attribution. After credential res - The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`. - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block). - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). +- Image-capable user messages preserve text/image order. Tool-role content remains a string; consecutive tool-result images are grouped into the following user message with `Attached image(s) from tool result:`. - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s and 413), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. Attachment reads retain their stable attachment failure code rather than becoming transport failures. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). ## Model Experience @@ -82,15 +92,15 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. The vision model also receives retained user and tool-result images as base64 data URLs; an over-budget older image is represented by the documented placeholder. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. #### Token effect -Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available. +Provider tokenization governs exact text and image-token input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning and over-budget images avoids paying those tokens again; cache-read usage is reported when available. #### KV Cache effect -An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, or history change may prevent reuse from the first changed token; reasoning passback appends during tool round trips. +An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends during tool round trips. ### DeepSeek response @@ -111,4 +121,5 @@ Loop-retained response blocks append to the next request and preserve its earlie - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). -- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. +- **Plugin-added content block types are skipped** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`. +- **Images are input-only durable attachments** — direct external URLs, the Files API, and assistant image output are not supported. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 21c55cfa32..b1a45a1f39 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -20,6 +20,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: @@ -30,12 +31,19 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek-V4-Flash + - id: private-vision + name: Private Vision + inputModalities: [text, image] - id: private-reasoner description: Company-hosted reasoning model contextWindow: 512000 ``` -该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 +该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。在视觉模型端点完成发布前,默认目录不会公布视觉模型,但部署方可以通过 `inputModalities: [text, image]` 主动添加。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 + +支持图片的 catalog 配置项可以声明 `inputModalities: [text, image]`。适配器通过 `ctx.attachments` 解析 user 和工具结果中的 `ImageBlock` 引用,校验已存储字节,再发送瞬态 `data:;base64,...` `image_url` 部分,不改变持久会话消息。纯文本模型与未列出模型会在凭据、附件或网络 I/O 前拒绝图片输入。System 和 assistant 历史仍不能包含图片;工具结果图片会在仅含字符串的 `tool` 消息后,通过单独的 `user` 消息发送。 + +`maxRequestImageBytes` 限制累计 base64 图片 payload,默认值为 20 MiB,为官方 30 MiB 请求正文限制中的文本、工具和 JSON 分帧保留余量。历史超过上限时,适配器会从最旧图片开始替换为固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`,直至请求可容纳;被省略的附件不会被读取。附件准入仍负责单图和单消息原始字节数、媒体类型、尺寸与像素限制。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 @@ -49,10 +57,11 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 动态配置(settings + credentials) -连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: +连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值、图片上限与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。三个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 - **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.attachments`**——图片请求会在请求时解析该服务,因此 Cordis 加载顺序不会冻结可选图片能力。服务缺失时,图片输入以 `UNSUPPORTED_CONTENT` 失败;纯文本调用不依赖该服务。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 @@ -70,11 +79,12 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 - 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 通过协议发送。 - 第一个思考模式分片携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。 - **推理回传规则**:对携带工具调用的 assistant 轮次,会将 `reasoning_content` 序列化回历史(思考模式 API 必需);对不含工具调用的轮次,它会被丢弃(不会使用,可节省 token)。 +- 支持图片的 user 消息会保留文本/图片顺序。Tool role 内容仍为字符串;连续工具结果中的图片会用 `Attached image(s) from tool result:` 汇总到随后一条 user 消息。 - Cache 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。 ## 错误 -非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 +非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400 和 413)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。附件读取会保留稳定的附件失败 code,不会变成传输失败。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 ## 模型体验 @@ -82,15 +92,15 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。视觉模型还会通过 base64 data URL 收到保留的 user 与工具结果图片;超出上限的较旧图片由已记录的占位文本表示。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。 #### Token 影响 -精确输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理则避免再次支付这些 token;可用时会报告 cache-read 用量。 +精确文本与图片 token 输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理和超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。 #### KV Cache 影响 -未更改的已组装前缀可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀或历史变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。 +未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。 ### DeepSeek 响应 @@ -111,4 +121,5 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 -- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 +- **会跳过插件添加的内容块类型**:核心文本与支持的图片块会被序列化,空工具输出会以字面 `(no output)` 通过协议发送。 +- **图片是仅输入的持久附件**:不支持直接外部 URL、Files API 和 assistant 图片输出。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 36ccad7994..52d022806a 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -32,6 +32,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -46,6 +47,7 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index bc30d46c3b..638d555b1e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -8,19 +8,21 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, + ModelModality, ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { serializeRequest } from './serialize.ts' +import { serializeRequest, serializeRequestWithImages } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' import { translate } from './translate.ts' @@ -38,6 +40,8 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } /** @@ -66,6 +70,8 @@ export interface DeepSeekConnectionOptions { models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs: number + /** Maximum accumulated base64 image payload in one request. */ + maxRequestImageBytes: number /** Provider-owned model-request retry policy, already resolved. */ retryPolicy: ResolvedRetryPolicy } @@ -83,6 +89,8 @@ export interface DeepSeekAdapterOptions { resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ resolveUserId: () => AnonymousUserId + /** Resolve the current durable attachment service; absence rejects image input. */ + resolveAttachments?: () => AttachmentStore | undefined } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -91,6 +99,8 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 export const DEFAULT_CONTEXT_WINDOW = 1_000_000 /** Default per-request output-token cap. */ export const DEFAULT_MAX_TOKENS = 256_000 +/** Default bound on accumulated base64 image payload per request. */ +export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' const OFF_REASONING_EFFORT = ReasoningEffortId('off') const LOW_REASONING_EFFORT = ReasoningEffortId('low') @@ -112,7 +122,7 @@ function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo id: model.id, name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, - inputModalities: ['text'], + inputModalities: model.inputModalities ?? ['text'], } } @@ -139,6 +149,7 @@ function requestId(headers: Headers): ReturnType | und */ export function httpErrorCode(status: number, error?: WireError['error']): string { if (status === 401 || status === 403) return 'AUTH' + if (status === 413) return 'INVALID_REQUEST' const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE if (status === 429) return 'RATE_LIMIT' @@ -184,10 +195,9 @@ export class DeepSeekAdapter extends LlmAdapter { const contextWindow = configured?.contextWindow ?? connection.defaultContextWindow return Promise.resolve({ - // The chat-completions wire route is text-only regardless of catalog - // membership, so the uncatalogued fallback declares the same negative - // capability — "unknown" here would let the host accept and persist - // images the serializer must then reject. + // An uncatalogued endpoint is safely treated as text-only. Declaring an + // unverified image capability would let the host persist input that the + // endpoint may reject on every later turn. ...configured === undefined ? { provider, id: model, name: model, inputModalities: ['text' as const] } : modelInfo(provider, configured), @@ -222,6 +232,24 @@ export class DeepSeekAdapter extends LlmAdapter { // The key resolves *from this snapshot*, so an endpoint and the secret // sent to it can never come from different configuration generations. const connection = this.config.options() + const hasImages = options.messages.some(message => contentHasImage(message.content)) + let attachments: AttachmentStore | undefined + if (hasImages) { + const model = connection.models.find(entry => entry.id === options.model) + if (model?.inputModalities?.includes('image') !== true) { + throw new LlmError( + `DeepSeek model "${options.model}" does not accept image input.`, + 'UNSUPPORTED_CONTENT', + ) + } + attachments = this.config.resolveAttachments?.() + if (attachments === undefined) { + throw new LlmError( + 'DeepSeek image conversion requires the durable attachment service.', + 'UNSUPPORTED_CONTENT', + ) + } + } const apiKey = await this.config.resolveApiKey(connection) const userId = this.config.resolveUserId() const consumer = new AbortController() @@ -235,6 +263,7 @@ export class DeepSeekAdapter extends LlmAdapter { connection, apiKey, userId, + attachments, () => { watchdog.pulse() }, )[Symbol.asyncIterator]() let exhausted = false @@ -278,9 +307,16 @@ export class DeepSeekAdapter extends LlmAdapter { connection: DeepSeekConnectionOptions, apiKey: string, userId: AnonymousUserId, + attachments: AttachmentStore | undefined, onComment: () => void, ): AsyncIterable { - const body = serializeRequest(options, connection.defaults) + const body = attachments === undefined + ? serializeRequest(options, connection.defaults) + : await serializeRequestWithImages(options, { + attachments, + maxRequestImageBytes: connection.maxRequestImageBytes, + signal, + }, connection.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index cbaa0c13b6..51382bb128 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -14,7 +14,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' -import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { launchEnvironmentOf, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -22,6 +22,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { DEFAULT_CONTEXT_WINDOW, + DEFAULT_MAX_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter, @@ -30,6 +31,7 @@ import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter. export { DEFAULT_CONTEXT_WINDOW, + DEFAULT_MAX_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter, @@ -51,6 +53,8 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW }, ] +const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[] + /** * Plugin config, validated by the same-named schemastery schema and doubling * as the `llm-deepseek` settings-section shape. Every field is optional in @@ -76,6 +80,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -86,6 +92,7 @@ const catalogModel: z = z.object({ description: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), + inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']), }) export const Config: z = z.object({ @@ -97,6 +104,7 @@ export const Config: z = z.object({ defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES), retryPolicy: RetryPolicySchema, }) @@ -134,6 +142,18 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`, ) } + const inputModalities = model.inputModalities ?? ['text'] + if (inputModalities.length === 0) { + throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not be empty`) + } + if (inputModalities.some(modality => !MODEL_MODALITIES.includes(modality))) { + throw new Error( + `llm-deepseek: catalog model "${model.id}" inputModalities must contain only "text" and "image"`, + ) + } + if (new Set(inputModalities).size !== inputModalities.length) { + throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { @@ -142,6 +162,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee ...model.description === undefined ? {} : { description: model.description }, ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }, + inputModalities: [...inputModalities], } }) } @@ -180,6 +201,10 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + const maxRequestImageBytes = config.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES + if (!Number.isSafeInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) { + throw new Error('llm-deepseek: maxRequestImageBytes must be a positive safe integer') + } return { apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL @@ -193,6 +218,7 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, models: resolveModels(config.models), streamIdleTimeoutMs, + maxRequestImageBytes, retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), } } @@ -247,7 +273,12 @@ export function apply(ctx: Context, config: Config): void { let userId: AnonymousUserId | undefined const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() - const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId }) + const adapter = new DeepSeekAdapter({ + options, + resolveApiKey, + resolveUserId, + resolveAttachments: () => ctx.get('attachments'), + }) ctx.llm.registerConfigurableProviders([ { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, ]) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index f51ef23031..bdf9869544 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -1,15 +1,22 @@ /** - * Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text - * becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages. - * Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by - * thinking-mode passback. Core image blocks are rejected explicitly because this wire route is text-only; - * unknown declaration-merged block types retain the adapter's documented extension fallback. + * Serialize harness messages into DeepSeek chat completions. Text-only + * requests retain string user content; the image path resolves durable + * attachments into ordered data-URL parts. Tool-result images follow their + * string-only tool messages in a separate user message. * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { WireMessage, WireRequest, WireTool } from './types.ts' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + WireImageContentPart, + WireMessage, + WireRequest, + WireTool, + WireUserContentPart, +} from './types.ts' /** Adapter-level request defaults (from plugin config). */ export interface RequestDefaults { @@ -22,6 +29,18 @@ interface ResolvedThinking { reasoningEffort?: 'low' | 'high' | 'max' } +/** Dependencies required only when the request contains image input. */ +export interface ImageSerializationOptions { + /** Durable resolver for canonical image references. */ + attachments: AttachmentStore + /** Positive bound on accumulated base64 image payload. */ + maxRequestImageBytes: number + /** Cancellation shared with the provider request. */ + signal: AbortSignal +} + +const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:' + /** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */ function reasoningEffort(effort: NonNullable): 'off' | 'low' | 'high' | 'max' { if (effort === 'off' || effort === 'low' || effort === 'high' || effort === 'max') { @@ -67,6 +86,73 @@ function assertTextOnly(blocks: readonly ContentBlock[]): void { } } +/** Reject roles whose DeepSeek history format cannot carry image input. */ +function assertSupportedImageRoles(messages: readonly Message[]): void { + for (const message of messages) { + if (message.role !== 'user' && contentHasImage(message.content)) { + throw new LlmError( + `The DeepSeek chat-completions adapter cannot represent image content in a ${message.role} message.`, + 'UNSUPPORTED_CONTENT', + ) + } + } +} + +/** Resolve one durable image into its transient DeepSeek data-URL part. */ +async function imagePart( + block: Extract, + attachments: AttachmentStore, + signal: AbortSignal, +): Promise { + try { + const stored = await attachments.readImage(block.attachment, signal) + return { + type: 'image_url', + image_url: { + url: `data:${stored.ref.mediaType};base64,${Buffer.from(stored.data).toString('base64')}`, + }, + } + } catch (error: unknown) { + if (error instanceof AttachmentError) { + throw new LlmError(error.message, error.code, { cause: error }) + } + throw error + } +} + +/** Convert user or nested tool-result blocks into ordered wire parts. */ +async function contentParts( + blocks: readonly ContentBlock[], + attachments: AttachmentStore, + signal: AbortSignal, +): Promise { + const parts: WireUserContentPart[] = [] + for (const block of blocks) { + switch (block.type) { + case 'text': + if (block.text.length > 0) parts.push({ type: 'text', text: block.text }) + break + case 'image': + parts.push(await imagePart(block, attachments, signal)) + break + case 'tool-result': + parts.push(...await contentParts(block.content, attachments, signal)) + break + default: + // Other merge-extensible blocks are not DeepSeek user-input vocabulary. + break + } + } + return parts +} + +/** Keep text-only user messages on the compact string wire form. */ +function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] { + return parts.some(part => part.type === 'image_url') + ? [...parts] + : parts.map(part => part.type === 'text' ? part.text : '').join('') +} + /** Serialize one assistant message (text + reasoning + tool calls). */ function serializeAssistant(message: Message): WireMessage { const text = flattenText(message.content) @@ -140,6 +226,102 @@ export function serializeMessages(messages: Message[]): WireMessage[] { return wire } +/** + * Serialize image-capable history after resolving durable attachments. + * Consecutive tool results keep string `tool` messages and share one following + * user message containing their images. + * @param messages - transient request history after request-size offloading. + * @param attachments - durable image resolver. + * @param signal - cancellation for attachment reads. + * @returns ordered DeepSeek wire messages. + */ +export async function serializeMessagesWithImages( + messages: readonly Message[], + attachments: AttachmentStore, + signal: AbortSignal, +): Promise { + assertSupportedImageRoles(messages) + const wire: WireMessage[] = [] + let pendingToolImages: WireImageContentPart[] = [] + const flushToolImages = (): void => { + if (pendingToolImages.length === 0) return + wire.push({ + role: 'user', + content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages], + }) + pendingToolImages = [] + } + + for (const message of messages) { + if (message.role === 'system') { + flushToolImages() + wire.push({ role: 'system', content: flattenText(message.content) }) + continue + } + if (message.role === 'assistant') { + flushToolImages() + wire.push(serializeAssistant(message)) + continue + } + + const regular = message.content.filter(block => block.type !== 'tool-result') + const toolResults = message.content.filter((block): block is Extract => ( + block.type === 'tool-result' + )) + if (regular.length > 0 || toolResults.length === 0) { + flushToolImages() + wire.push({ + role: 'user', + content: userContent(await contentParts(regular, attachments, signal)), + }) + } + for (const result of toolResults) { + const parts = await contentParts(result.content, attachments, signal) + const images = parts.filter((part): part is WireImageContentPart => part.type === 'image_url') + const text = parts.filter(part => part.type === 'text').map(part => part.text).join('') + wire.push({ + role: 'tool', + tool_call_id: result.toolCallId, + content: text || (images.length > 0 ? '(see attached image)' : '(no output)'), + }) + pendingToolImages.push(...images) + } + } + flushToolImages() + return wire +} + +/** Assemble request fields shared by text-only and image-capable conversion. */ +function requestWithMessages( + options: GenerateOptions, + messages: WireMessage[], + defaults: RequestDefaults, +): WireRequest { + const tools: WireTool[] | undefined = options.tools?.map(tool => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + })) + const resolvedThinking = resolveThinking(options, defaults) + return { + model: options.model, + messages, + stream: true, + stream_options: { include_usage: true }, + ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, + ...resolvedThinking.reasoningEffort !== undefined + ? { reasoning_effort: resolvedThinking.reasoningEffort } + : {}, + ...tools !== undefined && tools.length > 0 ? { tools } : {}, + ...options.temperature !== undefined ? { temperature: options.temperature } : {}, + ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, + ...options.stop !== undefined ? { stop: options.stop } : {}, + } +} + /** * Build the full wire request. Always streaming (`stream: true`, usage * reporting on); optional fields are omitted rather than sent as null, so @@ -158,30 +340,29 @@ export function serializeRequest( } messages.push(...serializeMessages(options.messages)) - const tools: WireTool[] | undefined = options.tools?.map(tool => ({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - })) - // A short title budget must produce visible text; conversation and - // compaction calls continue to inherit the adapter's thinking defaults. - const resolvedThinking = resolveThinking(options, defaults) + return requestWithMessages(options, messages, defaults) +} - return { - model: options.model, - messages, - stream: true, - stream_options: { include_usage: true }, - ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, - ...resolvedThinking.reasoningEffort !== undefined - ? { reasoning_effort: resolvedThinking.reasoningEffort } - : {}, - ...tools !== undefined && tools.length > 0 ? { tools } : {}, - ...options.temperature !== undefined ? { temperature: options.temperature } : {}, - ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, - ...options.stop !== undefined ? { stop: options.stop } : {}, +/** + * Build one image-capable request while keeping durable bytes out of session + * messages. Oversized oldest images become deterministic text before any + * attachment read. + * @param options - harness request containing image-capable user content. + * @param images - attachment resolver, request bound, and cancellation. + * @param defaults - adapter-level thinking defaults. + * @returns the fully materialized DeepSeek request body. + */ +export async function serializeRequestWithImages( + options: GenerateOptions, + images: ImageSerializationOptions, + defaults: RequestDefaults = {}, +): Promise { + assertSupportedImageRoles(options.messages) + const requestMessages = offloadRequestImages(options.messages, images.maxRequestImageBytes) + const messages: WireMessage[] = [] + if (options.system !== undefined) { + messages.push({ role: 'system', content: options.system }) } + messages.push(...await serializeMessagesWithImages(requestMessages, images.attachments, images.signal)) + return requestWithMessages(options, messages, defaults) } diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index ec3f28cbf4..1781ed8646 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -35,10 +35,25 @@ export interface WireSystemMessage { content: string } -/** User-role message: a single string of user input. */ +/** Text part inside a multimodal user message. */ +export interface WireTextContentPart { + type: 'text' + text: string +} + +/** Base64 data URL part inside a multimodal user message. */ +export interface WireImageContentPart { + type: 'image_url' + image_url: { url: string } +} + +/** Ordered input part accepted by a multimodal user message. */ +export type WireUserContentPart = WireTextContentPart | WireImageContentPart + +/** User-role message: text-only string or ordered multimodal input. */ export interface WireUserMessage { role: 'user' - content: string + content: string | WireUserContentPart[] } /** Tool-role message: the result of one tool call, keyed by its call id. */ diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 19ce411406..7845157fab 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,10 +1,18 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' @@ -18,6 +26,41 @@ import { assemble, type AssembledResult } from './assemble.ts' const FLASH = 'deepseek-v4-flash' const PRO = 'deepseek-v4-pro' +const VISION = 'deepseek-v4-flash-vision-exp' +const RED_IMAGE = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', + 'base64', +) +const RED_IMAGE_REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${createHash('sha256').update(RED_IMAGE).digest('hex')}`), + mediaType: 'image/png', + bytes: RED_IMAGE.byteLength, + width: 1, + height: 1, +} + +class E2eAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 1, + maxImageDimension: 1, + mediaTypes: ['image/png'], + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(RED_IMAGE_REF) + } + + readImage(_ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { + return Promise.resolve({ ref: RED_IMAGE_REF, data: RED_IMAGE }) + } +} const contexts: Context[] = [] let identityHome: string @@ -30,6 +73,7 @@ async function harness(_model: string, config: Partial = {}) { const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmRuntime) + await ctx.plugin(E2eAttachmentStore) await ctx.plugin(LlmDeepSeek, config) return ctx } @@ -65,6 +109,23 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { + it('recognizes a deterministic image with the official vision model', async () => { + const ctx = await harness(VISION, { thinking: 'disabled' }) + const result = await assemble(ctx, { + model: VISION, + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'This image is one solid color. Reply with only its English color name.' }, + { type: 'image', attachment: RED_IMAGE_REF }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + maxTokens: 50, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('red') + }) + it('serves a real request with the key held only by a credentials-local document', async () => { const key = process.env.DEEPSEEK_API_KEY if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ce56635ee7..cecb37bdff 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -3,6 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import LlmRuntime, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, @@ -47,15 +49,31 @@ async function harness(baseURL: string, config: object = {}) { } /** Direct adapter over the plugin's real resolve step, with a static key. */ -function adapterOf(config: Partial & { apiKey?: string } = {}): DeepSeekAdapter { +function adapterOf( + config: Partial & { apiKey?: string } = {}, + attachments?: AttachmentStore, +): DeepSeekAdapter { const { apiKey, ...rest } = config return new DeepSeekAdapter({ options: () => resolveAdapterOptions(rest), resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), resolveUserId: () => TEST_USER_ID, + resolveAttachments: () => attachments, }) } +async function drain(stream: AsyncIterable): Promise { + for await (const _chunk of stream) { /* drain */ } +} + +const imageRef: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} + describe('DeepSeekAdapter against a mock server', () => { it('streams a text generation end to end through the assembler', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -90,6 +108,93 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') }) + it('sends a durable image as a base64 data URL for the vision model', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const signalSeen: (AbortSignal | undefined)[] = [] + const attachments = { + readImage: vi.fn((ref: ImageAttachmentRef, signal?: AbortSignal) => { + signalSeen.push(signal) + return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) + }), + } as unknown as AttachmentStore + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'describe ' }, + { type: 'image', attachment: imageRef }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + expect(server.requests[0]).toMatchObject({ + model: 'deepseek-v4-flash-vision-exp', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'describe ' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }, + ], + }], + }) + expect(signalSeen[0]).toBeInstanceOf(AbortSignal) + }) + + it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( + 'rejects image input for text-only model %s before credentials, attachments, or fetch', + async (model) => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const resolveAttachments = vi.fn(() => ({}) as AttachmentStore) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + resolveAttachments, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(resolveAttachments).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }, + ) + + it('rejects vision input without an attachment provider before credentials or fetch', async () => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }) + it('streams raw chunks through ctx.llm.stream', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }]) const ctx = await harness(server.url) @@ -386,7 +491,7 @@ describe('DeepSeekAdapter against a mock server', () => { .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' })) .toBe('INVALID_REQUEST') - expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413') + expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('INVALID_REQUEST') }) it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { @@ -772,12 +877,13 @@ describe('plugin registration and config', () => { name: 'Private Reasoner', description: 'Higher reasoning budget', contextWindow: 64_000, + inputModalities: ['text', 'image'], }, ], }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) @@ -785,6 +891,7 @@ describe('plugin registration and config', () => { .resolves.toMatchObject({ name: 'Private Reasoner', description: 'Higher reasoning budget', + inputModalities: ['text', 'image'], }) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted')) .resolves.toMatchObject({ @@ -823,13 +930,21 @@ describe('plugin registration and config', () => { await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([]) }) - it.each([ + const invalidModels: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ [[{ id: '' }], /ids must be non-empty/], [[{ id: 'm', name: '' }], /empty name/], [[{ id: 'm', contextWindow: 0 }], /contextWindow/], [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], + [[{ id: 'm', inputModalities: [] }], /inputModalities/], + [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/], + [[{ + id: 'm', + inputModalities: ['audio'] as unknown as NonNullable, + }], /expected "text" \| "image"/], [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], - ] as const)('rejects invalid advisory model config', async (models, message) => { + ] + + it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => { const ctx = new Context() await ctx.plugin(LlmRuntime) await expect(ctx.plugin(LlmDeepSeek, { @@ -903,6 +1018,22 @@ describe('plugin registration and config', () => { }, ) + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid request image bound %s', + async (maxRequestImageBytes) => { + expect(() => resolveAdapterOptions({ maxRequestImageBytes })) + .toThrow(/maxRequestImageBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxRequestImageBytes, + })).rejects.toThrow(/maxRequestImageBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 84e78819b0..be7d4e3688 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -3,7 +3,14 @@ import { Context } from '@deepseek-ai/cordis' import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import LlmRuntime, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -14,6 +21,36 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const NS = settingsNamespace('llm-deepseek') const KEY_REF = credentialRef('DEEPSEEK_API_KEY') +const IMAGE_REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} + +class StaticAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 16, + maxImagesPerMessage: 4, + maxMessageImageBytes: 64, + maxImagePixels: 4, + maxImageDimension: 4, + mediaTypes: ['image/png'], + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(IMAGE_REF) + } + + readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { + return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) + } +} const cleanups: Array<() => Promise> = [] @@ -47,6 +84,7 @@ async function boot(dir: string, config: object): Promise { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) + await ctx.plugin(StaticAttachmentStore) const settingsFiber = ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) await settingsFiber await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) @@ -118,12 +156,44 @@ describe('request-level dynamic configuration', () => { const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) - await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) + await ctx.settings.update(NS, { + models: [{ id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }], + }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }, ]) }) + it('applies a changed request image bound to the next request', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const dir = await home() + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const { ctx } = await boot(dir, { + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }) + const messages = [createUserMessage({ + content: [ + { type: 'image', attachment: IMAGE_REF }, + { type: 'image', attachment: IMAGE_REF }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) + await ctx.settings.update(NS, { maxRequestImageBytes: 4 }) + await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) + + const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content + const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content + expect(JSON.stringify(first).match(/"type":"image_url"/g)).toHaveLength(2) + expect(JSON.stringify(second)).toContain('[image omitted to keep the request within its image limit') + expect(JSON.stringify(second).match(/"type":"image_url"/g)).toHaveLength(1) + }) + it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { const dir = await home() const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 714cfc5d27..059d6c0a54 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,13 +1,38 @@ -import { describe, expect, it } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { describe, expect, it, vi } from 'vitest' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { createUserMessage, CallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { serializeMessages, serializeRequest } from '../src/serialize.ts' +import { + serializeMessages, + serializeMessagesWithImages, + serializeRequest, + serializeRequestWithImages, +} from '../src/serialize.ts' function request(overrides: Partial = {}): GenerateOptions { return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides } } +function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAttachmentRef { + return { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType, + bytes, + width: 1, + height: 1, + } +} + +function attachmentStore( + readImage = vi.fn((ref: ImageAttachmentRef, _signal?: AbortSignal) => Promise.resolve({ + ref, + data: Uint8Array.of(1, 2, 3), + })), +): AttachmentStore { + return { readImage } as unknown as AttachmentStore +} + describe('serializeMessages', () => { it('maps user text to string content', () => { const wire = serializeMessages([ @@ -270,6 +295,159 @@ describe('serializeRequest', () => { }) }) +describe('image serialization', () => { + it.each([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + ] as const)('preserves ordered text and %s image parts', async (mediaType) => { + const signal = new AbortController().signal + const readImage = vi.fn((ref: ImageAttachmentRef, received?: AbortSignal) => { + expect(received).toBe(signal) + return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) + }) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'before' }, + { type: 'image', attachment: imageRef(mediaType) }, + { type: 'text', text: 'after' }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(readImage), + maxRequestImageBytes: 20 * 1024 * 1024, + signal, + }) + + expect(wire.messages).toEqual([{ + role: 'user', + content: [ + { type: 'text', text: 'before' }, + { type: 'image_url', image_url: { url: `data:${mediaType};base64,AQID` } }, + { type: 'text', text: 'after' }, + ], + }]) + }) + + it('serializes image-only user content without synthetic text', async () => { + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(), + maxRequestImageBytes: 20 * 1024 * 1024, + signal: new AbortController().signal, + }) + + expect(wire.messages).toEqual([{ + role: 'user', + content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }], + }]) + }) + + it('keeps tool content textual and groups consecutive tool-result images afterward', async () => { + const messages = [ + createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('first'), + content: [{ type: 'image', attachment: imageRef() }], + }], + source: { kind: 'plugin', plugin: 'test' }, + }), + createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('second'), + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', attachment: imageRef('image/jpeg') }, + ], + }], + source: { kind: 'plugin', plugin: 'test' }, + }), + ] + + await expect(serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + )).resolves.toEqual([ + { role: 'tool', tool_call_id: 'first', content: '(see attached image)' }, + { role: 'tool', tool_call_id: 'second', content: 'caption' }, + { + role: 'user', + content: [ + { type: 'text', text: 'Attached image(s) from tool result:' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }, + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } }, + ], + }, + ]) + }) + + it('offloads oldest images before reads and keeps the newest image', async () => { + const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ + ref, + data: Uint8Array.of(1, 2, 3), + })) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef('image/png', 3) }, + { type: 'image', attachment: imageRef('image/jpeg', 3) }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(readImage), + maxRequestImageBytes: 4, + signal: new AbortController().signal, + }) + + expect(wire.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } }, + ], + }) + expect(readImage).toHaveBeenCalledTimes(1) + expect(readImage.mock.calls[0]?.[0]).toMatchObject({ mediaType: 'image/jpeg' }) + }) + + it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => { + const readImage = vi.fn() + await expect(serializeMessagesWithImages([createMessage({ + role, + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], attachmentStore(readImage), new AbortController().signal)) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(readImage).not.toHaveBeenCalled() + }) + + it('preserves stable attachment failure codes', async () => { + const readImage = vi.fn(() => Promise.reject(new AttachmentError( + 'Stored attachment bytes are corrupt.', + 'ATTACHMENT_CORRUPT', + ))) + await expect(serializeMessagesWithImages([createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], attachmentStore(readImage), new AbortController().signal)) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) +}) + describe('review fixes: assistant content shapes', () => { it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => { // Aborted/empty assistant turns: no text, no calls → "". The earlier diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 5a2d330b7d..c0391b7bad 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,7 +4,7 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' @@ -26,82 +26,17 @@ function toolResultText(blocks: readonly ContentBlock[]): string { : block.type === 'tool-result' ? toolResultText(block.content) : '').join('') } -/** Model-facing stand-in for an image dropped to fit the request bound. */ -export const OFFLOADED_IMAGE_TEXT - = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' - -/** Base64 length of `bytes` raw bytes (4 output characters per 3 input bytes, padded). */ -function base64Length(bytes: number): number { - return Math.ceil(bytes / 3) * 4 -} - -/** - * Select the images a request must drop to fit the per-request payload bound. - * History order is oldest-first, so the most recent images are omitted last. - * A single image larger than the bound is itself omitted. Locations use - * message and nested block indexes so JSON replay cannot change the result by - * splitting or preserving shared object identities. - * @param messages - complete request history, oldest first. - * @param maxRequestImageBytes - bound on total base64-encoded image payload; undefined leaves every image in place. - * @returns the image locations the conversion replaces with {@link OFFLOADED_IMAGE_TEXT}. - */ -function offloadedImages( - messages: readonly Message[], - maxRequestImageBytes: number | undefined, -): ReadonlySet { - const offloaded = new Set() - if (maxRequestImageBytes === undefined) return offloaded - const images: { location: string; base64Bytes: number }[] = [] - const collect = (messageIndex: number, blocks: readonly ContentBlock[], prefix: readonly number[] = []): void => { - for (const [blockIndex, block] of blocks.entries()) { - const path = [...prefix, blockIndex] - if (block.type === 'image') { - images.push({ - location: `${messageIndex}:${path.join('.')}`, - base64Bytes: base64Length(block.attachment.bytes), - }) - } else if (block.type === 'tool-result') { - collect(messageIndex, block.content, path) - } - } - } - for (const [messageIndex, message] of messages.entries()) collect(messageIndex, message.content) - let total = images.reduce((sum, image) => sum + image.base64Bytes, 0) - for (const image of images) { - if (total <= maxRequestImageBytes) break - offloaded.add(image.location) - total -= image.base64Bytes - } - return offloaded -} - -interface LocatedContentBlock { - readonly block: ContentBlock - readonly path: readonly number[] -} - -/** Attach stable nested indexes to blocks from one message. */ -function locatedBlocks(blocks: readonly ContentBlock[], prefix: readonly number[] = []): LocatedContentBlock[] { - return blocks.map((block, index) => ({ block, path: [...prefix, index] })) -} - async function userContent( - blocks: readonly LocatedContentBlock[], + blocks: readonly ContentBlock[], attachments: AttachmentStore, - offloaded: ReadonlySet, - messageIndex: number, ): Promise { const content: (TextContent | ImageContent)[] = [] - for (const { block, path } of blocks) { + for (const block of blocks) { switch (block.type) { case 'text': if (block.text.length > 0) content.push({ type: 'text', text: block.text }) break case 'image': { - if (offloaded.has(`${messageIndex}:${path.join('.')}`)) { - content.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT }) - break - } const stored = await attachments.readImage(block.attachment) content.push({ type: 'image', @@ -112,7 +47,7 @@ async function userContent( } case 'tool-result': { - const nested = await userContent(locatedBlocks(block.content, path), attachments, offloaded, messageIndex) + const nested = await userContent(block.content, attachments) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -234,11 +169,11 @@ async function toPiContextWithImages( onReplayDegrade?: (reason: string) => void, maxRequestImageBytes?: number, ): Promise { - const offloaded = offloadedImages(options.messages, maxRequestImageBytes) + const requestMessages = offloadRequestImages(options.messages, maxRequestImageBytes) const toolNames = new Map() const messages: PiMessage[] = [] - for (const [messageIndex, message] of options.messages.entries()) { + for (const message of requestMessages) { if (message.role === 'system') { if (contentHasImage(message.content)) { throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') @@ -258,17 +193,16 @@ async function toPiContextWithImages( continue } // user role: text + tool results (each result becomes its own message). - const located = locatedBlocks(message.content) - const regular = located.filter(({ block }) => block.type !== 'tool-result') - const content = await userContent(regular, attachments, offloaded, messageIndex) - const results = located.filter((entry): entry is LocatedContentBlock & { block: Extract } => ( - entry.block.type === 'tool-result' + const regular = message.content.filter(block => block.type !== 'tool-result') + const content = await userContent(regular, attachments) + const results = message.content.filter((block): block is Extract => ( + block.type === 'tool-result' )) if (content.length > 0 || results.length === 0) { messages.push({ role: 'user', content, timestamp: 0 }) } - for (const { block: result, path } of results) { - const resultContent = await userContent(locatedBlocks(result.content, path), attachments, offloaded, messageIndex) + for (const result of results) { + const resultContent = await userContent(result.content, attachments) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index be41a12b07..2ab90d0c21 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { CallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { OFFLOADED_IMAGE_TEXT, toPiContext } from '../src/context.ts' +import { toPiContext } from '../src/context.ts' import { toPiAssistant } from '../src/replay.ts' const ref: ImageAttachmentRef = { diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 19b760a02a..55c0719fb9 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -1,6 +1,11 @@ /** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */ import type { ContentBlock } from './types.ts' +import type { Message } from './message.ts' + +/** Model-facing stand-in for an image removed to fit a provider request bound. */ +export const OFFLOADED_IMAGE_TEXT + = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' /** * True when typed model content contains an image block, walking nested @@ -14,3 +19,76 @@ export function contentHasImage(content: readonly ContentBlock[]): boolean { return content.some(block => block.type === 'image' || (block.type === 'tool-result' && contentHasImage(block.content))) } + +/** Base64 length of raw image bytes, including padding. */ +function base64Length(bytes: number): number { + return Math.ceil(bytes / 3) * 4 +} + +/** Collect base64 payload lengths in request and nested-block order. */ +function collectImageLengths(blocks: readonly ContentBlock[], lengths: number[]): void { + for (const block of blocks) { + if (block.type === 'image') { + lengths.push(base64Length(block.attachment.bytes)) + } else if (block.type === 'tool-result') { + collectImageLengths(block.content, lengths) + } + } +} + +/** Replace the first `remaining.count` image occurrences without mutating durable messages. */ +function replaceOldestImages( + blocks: readonly ContentBlock[], + remaining: { count: number }, +): ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of blocks.entries()) { + if (block.type === 'image' && remaining.count > 0) { + remaining.count -= 1 + next ??= blocks.slice(0, index) + next.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT }) + continue + } + if (block.type === 'tool-result') { + const content = replaceOldestImages(block.content, remaining) + if (content !== block.content) { + next ??= blocks.slice(0, index) + next.push({ ...block, content }) + continue + } + } + next?.push(block) + } + return next ?? blocks as ContentBlock[] +} + +/** + * Return transient request messages whose oldest images are replaced until + * their accumulated base64 payload fits the configured bound. The selection + * is deterministic from durable message order and attachment metadata; a + * provider can serialize the returned messages without reading omitted bytes. + * @param messages - complete request history, oldest first. + * @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image. + * @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees. + */ +export function offloadRequestImages( + messages: readonly Message[], + maxRequestImageBytes: number | undefined, +): readonly Message[] { + if (maxRequestImageBytes === undefined) return messages + const lengths: number[] = [] + for (const message of messages) collectImageLengths(message.content, lengths) + let total = lengths.reduce((sum, bytes) => sum + bytes, 0) + let count = 0 + for (const bytes of lengths) { + if (total <= maxRequestImageBytes) break + total -= bytes + count += 1 + } + if (count === 0) return messages + const remaining = { count } + return messages.map((message) => { + const content = replaceOldestImages(message.content, remaining) + return content === message.content ? message : { ...message, content } + }) +} diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts new file mode 100644 index 0000000000..e61a585230 --- /dev/null +++ b/packages/llm/llm/tests/content.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { CallId, createUserMessage, OFFLOADED_IMAGE_TEXT, offloadRequestImages } from '../src/index.ts' +import type { ContentBlock } from '../src/index.ts' + +const source = { kind: 'plugin' as const, plugin: 'test' } + +function image(bytes: number): ContentBlock { + return { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes, + width: 1, + height: 1, + }, + } +} + +describe('offloadRequestImages', () => { + it('preserves the original request when its base64 payload fits exactly', () => { + const messages = [createUserMessage({ content: [image(3), image(3)], source })] + expect(offloadRequestImages(messages, 8)).toBe(messages) + }) + + it('keeps five 3 MiB images at 20 MiB and offloads the oldest after one more raw byte', () => { + const rawImageBytes = 3 * 1024 * 1024 + const maxRequestImageBytes = 20 * 1024 * 1024 + const exact = [createUserMessage({ + content: Array.from({ length: 5 }, () => image(rawImageBytes)), + source, + })] + expect(offloadRequestImages(exact, maxRequestImageBytes)).toBe(exact) + + const over = [createUserMessage({ + content: [image(rawImageBytes + 1), ...Array.from({ length: 4 }, () => image(rawImageBytes))], + source, + })] + expect(offloadRequestImages(over, maxRequestImageBytes)[0]?.content).toEqual([ + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + ...Array.from({ length: 4 }, () => image(rawImageBytes)), + ]) + }) + + it('replaces the oldest nested occurrences without mutating durable messages', () => { + const shared = image(3) + const messages = [ + createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('shot'), + content: [shared], + }], + source, + }), + createUserMessage({ content: [shared, image(3)], source }), + ] + + const fitted = offloadRequestImages(messages, 8) + expect(fitted).not.toBe(messages) + expect(fitted[0]?.content).toEqual([{ + type: 'tool-result', + toolCallId: CallId('shot'), + content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }], + }]) + expect(fitted[1]?.content).toEqual([shared, image(3)]) + expect(messages[0]?.content[0]).toMatchObject({ type: 'tool-result', content: [shared] }) + }) + + it('replaces a single image that cannot fit', () => { + const messages = [createUserMessage({ content: [image(300)], source })] + expect(offloadRequestImages(messages, 8)[0]?.content) + .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96bfb5d0de..235eb47887 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5313,6 +5313,9 @@ importers: '@deepseek-ai/dsh-anonymous-user-id': specifier: workspace:^ version: link:../../identity/anonymous-user-id + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials From 4a02791c9a72e89c8c442a0a0972d06ddfc89361 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 19 Aug 2026 12:47:24 +0800 Subject: [PATCH 55/56] fix(llm): address multimodal review findings --- ...-19-direct-deepseek-vision-input.i18n.yaml | 4 +- ...2026-08-19-direct-deepseek-vision-input.md | 2 +- ...6-08-19-direct-deepseek-vision-input.zh.md | 2 +- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/llm/llm-deepseek/src/serialize.ts | 14 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 61 --------- .../llm/llm-deepseek/tests/adapter.spec.ts | 32 ++++- .../llm/llm-deepseek/tests/serialize.spec.ts | 129 ++++++++++++++++++ packages/llm/llm-pi-ai/src/context.ts | 16 ++- packages/llm/llm-pi-ai/tests/context.spec.ts | 64 ++++++++- packages/llm/llm/tests/content.spec.ts | 13 ++ 15 files changed, 268 insertions(+), 83 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml index a2caf36d87..d46a50b6d3 100644 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md -2026-08-19-direct-deepseek-vision-input.md: 60f55f10e108a29d465e388fa385b1955b7f2ce5 -2026-08-19-direct-deepseek-vision-input.zh.md: 7f41d89ffd13da1b1b170f114dfc481e66075ef2 +2026-08-19-direct-deepseek-vision-input.md: 5c2f2ea3ff87b58906ce76caf9f611e07f3e2d04 +2026-08-19-direct-deepseek-vision-input.zh.md: 3aea6b6a4f27f7116c1cb935b1719dfbcf7ed8fc diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md index 60f55f10e1..5c2f2ea3ff 100644 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md @@ -27,7 +27,7 @@ Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist ## Verification -Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. A key-gated real-API e2e sends a deterministic image to the official vision model. +Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md index 7f41d89ffd..3aea6b6a4f 100644 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md @@ -27,7 +27,7 @@ DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek- ## Verification -包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。受密钥控制的真实 API e2e 会向官方视觉模型发送一张确定性图片。 +包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。 ## Consequences diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7f1120a1df..6a9a3acf1f 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md config-catalog.md: 7d42923c1bc4c73636b52713694ff8f6eeb9146a -config-catalog.zh.md: b9327fa127ba8e341a15954685942f2d15d6b8d3 +config-catalog.zh.md: 327a4951c513e7cd2360488026e7578e95458f47 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b9327fa127..327a4951c5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -897,7 +897,7 @@ export interface DeepSeekCatalogModel { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:72`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1b0e8fad9c..eea3a0a930 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 0bd5f80534ba65e0d483bd04228cdac321e082bc -module-graph.zh.md: 6239520b7d819eb14c2b859afa44a0c987545200 +module-graph.md: 5398515912c98962152d2b121b1b02d7a06097b9 +module-graph.zh.md: 7a398374603d66f8a3661a1d927b6418a99235a6 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0bd5f80534..5398515912 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -407,6 +407,7 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -1463,7 +1464,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6239520b7d..7a39837460 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -409,6 +409,7 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -1465,7 +1466,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index bdf9869544..da643aa599 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -148,9 +148,12 @@ async function contentParts( /** Keep text-only user messages on the compact string wire form. */ function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] { - return parts.some(part => part.type === 'image_url') - ? [...parts] - : parts.map(part => part.type === 'text' ? part.text : '').join('') + const text: string[] = [] + for (const part of parts) { + if (part.type === 'image_url') return [...parts] + text.push(part.text) + } + return text.join('') } /** Serialize one assistant message (text + reasoning + tool calls). */ @@ -268,11 +271,12 @@ export async function serializeMessagesWithImages( const toolResults = message.content.filter((block): block is Extract => ( block.type === 'tool-result' )) - if (regular.length > 0 || toolResults.length === 0) { + const content = userContent(await contentParts(regular, attachments, signal)) + if (content.length > 0 || toolResults.length === 0) { flushToolImages() wire.push({ role: 'user', - content: userContent(await contentParts(regular, attachments, signal)), + content, }) } for (const result of toolResults) { diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 7845157fab..19ce411406 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,18 +1,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' -import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { - ImageAttachmentLimits, - ImageAttachmentRef, - SaveImageAttachment, - StoredImageAttachment, -} from '@deepseek-ai/dsh-attachment' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' @@ -26,41 +18,6 @@ import { assemble, type AssembledResult } from './assemble.ts' const FLASH = 'deepseek-v4-flash' const PRO = 'deepseek-v4-pro' -const VISION = 'deepseek-v4-flash-vision-exp' -const RED_IMAGE = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', - 'base64', -) -const RED_IMAGE_REF: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${createHash('sha256').update(RED_IMAGE).digest('hex')}`), - mediaType: 'image/png', - bytes: RED_IMAGE.byteLength, - width: 1, - height: 1, -} - -class E2eAttachmentStore extends AttachmentStore { - readonly imageLimits: ImageAttachmentLimits = { - maxImageBytes: 1024, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1024, - maxImagePixels: 1, - maxImageDimension: 1, - mediaTypes: ['image/png'], - } - - validateImage(_input: SaveImageAttachment): Promise { - return Promise.resolve() - } - - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve(RED_IMAGE_REF) - } - - readImage(_ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { - return Promise.resolve({ ref: RED_IMAGE_REF, data: RED_IMAGE }) - } -} const contexts: Context[] = [] let identityHome: string @@ -73,7 +30,6 @@ async function harness(_model: string, config: Partial = {}) { const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmRuntime) - await ctx.plugin(E2eAttachmentStore) await ctx.plugin(LlmDeepSeek, config) return ctx } @@ -109,23 +65,6 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { - it('recognizes a deterministic image with the official vision model', async () => { - const ctx = await harness(VISION, { thinking: 'disabled' }) - const result = await assemble(ctx, { - model: VISION, - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'This image is one solid color. Reply with only its English color name.' }, - { type: 'image', attachment: RED_IMAGE_REF }, - ], - source: { kind: 'plugin', plugin: 'test' }, - })], - maxTokens: 50, - }) - expect(result.finish.kind).toBe('stop') - expect(textOf(result).toLowerCase()).toContain('red') - }) - it('serves a real request with the key held only by a credentials-local document', async () => { const key = process.env.DEEPSEEK_API_KEY if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index cecb37bdff..6d86691e5c 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -178,7 +178,10 @@ describe('DeepSeekAdapter against a mock server', () => { const server = await mockServer([]) const resolveApiKey = vi.fn(() => Promise.resolve('k')) const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ baseURL: server.url }), + options: () => resolveAdapterOptions({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }), resolveApiKey, resolveUserId: () => TEST_USER_ID, }) @@ -865,6 +868,21 @@ describe('plugin registration and config', () => { ]) }) + it('defaults an adapter-supplied catalog entry to text input', async () => { + const connection = resolveAdapterOptions({ models: [] }) + const adapter = new DeepSeekAdapter({ + options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + }) + await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ + provider: 'deepseek-official', + id: 'adapter-model', + name: 'adapter-model', + inputModalities: ['text'], + }]) + }) + it('advertises configured models without restricting arbitrary request ids', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) @@ -954,6 +972,18 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + const invalidProgrammaticModalities: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ + [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/], + [[{ + id: 'm', + inputModalities: ['audio'] as unknown as NonNullable, + }], /inputModalities must contain only "text" and "image"/], + ] + + it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => { + expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message) + }) + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) .toThrow(/maxTokens must be a positive integer/) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 059d6c0a54..d503a8b5c7 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -393,6 +393,95 @@ describe('image serialization', () => { ]) }) + it('does not emit an empty user message for ignored content beside a tool result', async () => { + const messages = [createUserMessage({ + content: [ + { type: 'text', text: '' }, + { type: 'chart', data: 'ignored' } as unknown as ContentBlock, + { + type: 'tool-result', + toolCallId: CallId('result'), + content: [{ type: 'text', text: 'ok' }], + }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + await expect(serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + )).resolves.toEqual([ + { role: 'tool', tool_call_id: 'result', content: 'ok' }, + ]) + }) + + it('recursively converts nested tool-result content and preserves the empty fallback', async () => { + const messages = [createUserMessage({ + content: [ + { + type: 'tool-result', + toolCallId: CallId('nested'), + content: [{ + type: 'tool-result', + toolCallId: CallId('inner'), + content: [{ type: 'text', text: 'inside' }], + }], + }, + { type: 'tool-result', toolCallId: CallId('empty'), content: [] }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + await expect(serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + )).resolves.toEqual([ + { role: 'tool', tool_call_id: 'nested', content: 'inside' }, + { role: 'tool', tool_call_id: 'empty', content: '(no output)' }, + ]) + }) + + it('flushes tool-result images before system and assistant history', async () => { + const imageResult = (id: string) => createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId(id), + content: [{ type: 'image', attachment: imageRef() }], + }], + source: { kind: 'plugin' as const, plugin: 'test' }, + }) + const messages = [ + imageResult('before-system'), + createMessage({ + role: 'system', + content: [{ type: 'text', text: 'system history' }], + source: { kind: 'plugin', plugin: 'test' }, + }), + imageResult('before-assistant'), + createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'assistant history' }], + source: { kind: 'plugin', plugin: 'test' }, + }), + ] + + const wire = await serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + ) + expect(wire).toEqual([ + { role: 'tool', tool_call_id: 'before-system', content: '(see attached image)' }, + expect.objectContaining({ role: 'user' }), + { role: 'system', content: 'system history' }, + { role: 'tool', tool_call_id: 'before-assistant', content: '(see attached image)' }, + expect.objectContaining({ role: 'user' }), + { role: 'assistant', content: 'assistant history' }, + ]) + }) + it('offloads oldest images before reads and keeps the newest image', async () => { const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, @@ -435,6 +524,37 @@ describe('image serialization', () => { expect(readImage).not.toHaveBeenCalled() }) + it('rejects unsupported image history before request offloading can replace it', async () => { + const readImage = vi.fn() + await expect(serializeRequestWithImages(request({ + messages: [createMessage({ + role: 'system', + content: [{ type: 'image', attachment: imageRef('image/png', 300) }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(readImage), + maxRequestImageBytes: 1, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(readImage).not.toHaveBeenCalled() + }) + + it('prepends the request system prompt on the image path', async () => { + const wire = await serializeRequestWithImages(request({ + system: 'system prompt', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(), + maxRequestImageBytes: 20 * 1024 * 1024, + signal: new AbortController().signal, + }) + expect(wire.messages[0]).toEqual({ role: 'system', content: 'system prompt' }) + }) + it('preserves stable attachment failure codes', async () => { const readImage = vi.fn(() => Promise.reject(new AttachmentError( 'Stored attachment bytes are corrupt.', @@ -446,6 +566,15 @@ describe('image serialization', () => { })], attachmentStore(readImage), new AbortController().signal)) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) }) + + it('preserves non-attachment resolver failures', async () => { + const failure = new Error('resolver failed') + const readImage = vi.fn(() => Promise.reject(failure)) + await expect(serializeMessagesWithImages([createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], attachmentStore(readImage), new AbortController().signal)).rejects.toBe(failure) + }) }) describe('review fixes: assistant content shapes', () => { diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index c0391b7bad..d66a48115d 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -26,6 +26,18 @@ function toolResultText(blocks: readonly ContentBlock[]): string { : block.type === 'tool-result' ? toolResultText(block.content) : '').join('') } +/** Reject image roles that pi-ai cannot replay before request-size offloading can replace them. */ +function assertSupportedImageRoles(messages: readonly Message[]): void { + for (const message of messages) { + if (message.role !== 'user' && contentHasImage(message.content)) { + throw new LlmError( + `pi-ai cannot represent an image in an in-history ${message.role} message`, + 'UNSUPPORTED_CONTENT', + ) + } + } +} + async function userContent( blocks: readonly ContentBlock[], attachments: AttachmentStore, @@ -169,15 +181,13 @@ async function toPiContextWithImages( onReplayDegrade?: (reason: string) => void, maxRequestImageBytes?: number, ): Promise { + assertSupportedImageRoles(options.messages) const requestMessages = offloadRequestImages(options.messages, maxRequestImageBytes) const toolNames = new Map() const messages: PiMessage[] = [] for (const message of requestMessages) { if (message.role === 'system') { - if (contentHasImage(message.content)) { - throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') - } // pi-ai has a single systemPrompt slot; in-history system messages are // folded into user messages to preserve order (rare in practice — the // harness sends the system prompt via options.system). diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 2ab90d0c21..b82c6b63f5 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -140,6 +140,59 @@ describe('pi-ai request context conversion', () => { ]) }) + it('recursively converts nested tool-result text and images', async () => { + const callId = CallId('nested-call') + const context = await toPiContext(request([user([{ + type: 'tool-result', + toolCallId: callId, + content: [ + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'nested text' }], + }, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'image', attachment: ref }], + }, + ], + }])]), attachments) + + expect(context.messages).toEqual([{ + role: 'toolResult', + toolCallId: 'nested-call', + toolName: 'unknown', + content: [ + { type: 'text', text: 'nested text' }, + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + ], + isError: false, + timestamp: 0, + }]) + }) + + it('flattens nested text-only tool results and ignores other block types without storage', () => { + const callId = CallId('nested-text') + expect(toPiContext(request([user([{ + type: 'tool-result', + toolCallId: callId, + content: [ + { type: 'chart', data: 'ignored' } as unknown as ContentBlock, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'nested' }], + }, + ], + }])]))).toMatchObject({ + messages: [{ + role: 'toolResult', + content: [{ type: 'text', text: 'nested' }], + }], + }) + }) + it('replaces the oldest images with placeholders once the request payload bound is exceeded', async () => { const readImage = vi.fn(() => Promise.resolve({ ref: { ...ref, bytes: 3 }, data: Uint8Array.of(1, 2, 3) })) const store = { readImage } as unknown as AttachmentStore @@ -249,9 +302,14 @@ describe('pi-ai request context conversion', () => { }) it('handles in-history system and assistant messages explicitly on the image path', async () => { - await expect(toPiContext(request([ - history('system', [{ type: 'image', attachment: ref }]), - ]), attachments)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + for (const role of ['system', 'assistant'] as const) { + const readImage = vi.fn() + const store = { readImage } as unknown as AttachmentStore + await expect(toPiContext(request([ + history(role, [{ type: 'image', attachment: ref }]), + ]), store, undefined, 1)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(readImage).not.toHaveBeenCalled() + } await expect(toPiContext(request([ history('system', [{ type: 'text', text: 'history system' }]), diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index e61a585230..ffb5a586bf 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -73,4 +73,17 @@ describe('offloadRequestImages', () => { expect(offloadRequestImages(messages, 8)[0]?.content) .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }]) }) + + it('keeps unchanged nested content while replacing a later image', () => { + const nested = { + type: 'tool-result' as const, + toolCallId: CallId('text-only'), + content: [{ type: 'text' as const, text: 'kept' }], + } + const messages = [createUserMessage({ content: [nested, image(3)], source })] + expect(offloadRequestImages(messages, 1)[0]?.content).toEqual([ + nested, + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + ]) + }) }) From e2ee5c0f5dc914b34a3e870e2c90c3f4505278d3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 13:22:38 +0800 Subject: [PATCH 56/56] feat(feedback): float the note editor in a popover, not inline in the actions row The message-feedback note editor previously expanded inline inside the assistant message's shared IconActions row, overflowing the row at every viewport and pushing the branch action and the clock out of the column. Rework it into a portable popover: the note editor is a fixed-position panel portaled to document.body and anchored to the note trigger, so the row keeps its single 28px line whether the editor is open or not and the panel escapes the column's overflow clip. The like/dislike buttons and the note trigger stay in the row unchanged; the trigger toggles the popover (textarea + Save/Cancel + note-save failure) which auto-focuses the input and closes on Escape or an outside click, returning focus to the trigger. Rating/list-load failures surface in the row, note-save failures inside the popover. Reuse the Menu portal surface tokens and add @types/react-dom so the createPortal usage typechecks. Layout e2e sweeps six viewports and pins that opening the editor leaves the row's overflow, line count, and out-of-column items unchanged, and that the panel is outside the column, within the viewport, and anchored to its trigger. Unit tests cover the popover's portal-to-body, toggle, Escape/outside-click dismissal, and unmount early-returns. --- ...-13-feedback-note-editor-popover.i18n.yaml | 6 + ...2026-08-13-feedback-note-editor-popover.md | 43 ++ ...6-08-13-feedback-note-editor-popover.zh.md | 43 ++ apps/web/tests/message-feedback-layout.e2e.ts | 336 ++++++++++++ .../geometry.expected.md | 10 + apps/web/tsconfig.json | 1 + .../ui-message-feedback/README.i18n.yaml | 4 +- packages/client/ui-message-feedback/README.md | 2 +- .../client/ui-message-feedback/README.zh.md | 2 +- .../client/ui-message-feedback/package.json | 1 + .../client/MessageFeedbackActions.module.css | 62 ++- .../src/client/MessageFeedbackActions.tsx | 259 +++++++-- .../ui-message-feedback/src/client/locales.ts | 2 + .../message-feedback-actions.client.spec.tsx | 492 +++++++++++++++++- .../tests/styles.client.spec.ts | 93 ++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 2 + .../ui-primitives/src/useAnchoredPosition.ts | 81 +++ .../use-anchored-position.client.spec.tsx | 111 ++++ pnpm-lock.yaml | 3 + tsconfig.host.json | 1 + 23 files changed, 1496 insertions(+), 66 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md create mode 100644 apps/web/tests/message-feedback-layout.e2e.ts create mode 100644 apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md create mode 100644 packages/client/ui-message-feedback/tests/styles.client.spec.ts create mode 100644 packages/client/ui-primitives/src/useAnchoredPosition.ts create mode 100644 packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml new file mode 100644 index 0000000000..d84f437915 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md +2026-08-13-feedback-note-editor-popover.md: 33b7cd84b97ceac7fef1d96f1dee279fbb215800 +2026-08-13-feedback-note-editor-popover.zh.md: b130ab1e9c66372cf2a52bc5e12f65027c64cf51 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md new file mode 100644 index 0000000000..33b7cd84b9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md @@ -0,0 +1,43 @@ +# Agent Note: The feedback note editor floats above the transcript in a popover + +Status: implemented + +English | [中文](2026-08-13-feedback-note-editor-popover.zh.md) + +## Problem + +The Web surface for message feedback ([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262)) contributes its controls to `conversation.chat.assistant-actions`, which renders inside the finalized assistant message's shared IconActions row. That row was one fixed-height `flex` line with `flex-wrap` at its initial `nowrap` and `height: 28px`, sized for 28px icons and a clock. The note editor mounted into it as an inline group holding a `width: 260px` textarea plus Save and Cancel. + +A 260px input and two buttons do not fit that line at any window size. Measured against the shipped bundle, the row's scrollable overflow with the editor open was 168px at a 1680px viewport and 444px at 600px — the defect was never a narrow-window edge case, it was present at full-screen desktop. Flex overflow spills past the end of the line, so the items after the editor in flex order were the ones pushed out of the conversation column: the branch action left the column at 600px, and the clock and its run/TTFT/throughput readings left it at 900px. Those controls stay hit-testable while invisible, so no behavioral assertion noticed; the shipped e2e covered rate, note, reload, and retract, and the 24 UI snapshots are width-independent DOM. + +The same stylesheet also named four `--dsw-alias-*` tokens that the theme does not define: `border-secondary`, `bg-primary`, `interactive-bg-primary`, and `label-inverse`. An undefined custom property makes its whole declaration invalid at computed-value time, so the textarea shipped with no border and no surface, and Save with neither fill nor a readable label — the editor read as loose text floating in the transcript rather than as an input. + +## Decision + +The note editor does not enter the row's flex layout at all. It is a popover: a fixed-position panel, portaled to `document.body`, whose coordinates come from the note trigger's rect. The row keeps its single line of icons and the note trigger, so nothing has to shrink, wrap, or reflow around the editor, and no `order` or wrapping is needed anywhere. Portaling out of the conversation column also escapes its `overflow` clip, so the panel cannot be cropped at the scroll edge and it moves with the message it annotates when the transcript scrolls. This reuses the same portal mechanism `ui-primitives/Menu` uses for anchored menus (`ui-subagent`'s catalog popover is built on it): the panel is `position: fixed`, placed from the anchor rect on open, clamped inside the viewport, and re-placed on scroll (capture phase) and resize. That anchoring is shared rather than copied: `ui-primitives/useAnchoredPosition` owns measure-offset-clamp-and-track, and the duplication gate is what forced the extraction — an inline copy of the clamp and its listener pair reported a 10-line clone against `Menu`. `Menu` keeps its own effect because its placement also resolves `side`/`align` variants and an optional caller-supplied anchor rect, which this surface does not need; the hook covers the plain below-the-anchor case both would otherwise spell out. + +**The action strip.** The like/dislike buttons and the note trigger stay in the row, unchanged. The trigger is a plain button (`aria-haspopup="dialog"`, `aria-expanded` while open) that shows "Add a note" before a note exists and the note text afterward. + +**The popover.** While open, the panel contains the textarea plus Save and Cancel, and any note-save failure, as `role="dialog"` with a title distinct from the textarea's own label so both are addressable by name. It opens beneath the trigger (4px gap), clamps to 12px from the viewport edges, auto-focuses the textarea, and closes on Escape or an outside pointer-down. Closing returns focus to the trigger only when the panel was really open, never on the initial mount (a freshly rendered rated message must not pull focus into its action row). A rating action during an open editor closes the panel. The four undefined tokens are replaced with the ones the theme actually defines, matching the primitives' precedent: `border-l2` and `bg-layer-1` for the input, `button-primary-fill` with `label-primary-foreground` plus a `button-primary-hover` state for Save; the panel surface reuses the Menu card recipe (`--dsw-specific-menu`, `--dsw-shadow-lv3`, inverted hairline `--dsw-alias-border-inverted`, `border-radius: 12px`). + +**Failure surfaces split by where the human is looking.** A rating or list-load failure shows beside the buttons in the row, legible whether or not the popover is open. A note-save failure shows inside the popover, next to Save/Cancel, and the panel stays open so the draft survives to be corrected. + +## Alternatives considered + +**Inline expansion on the row, the editor claiming its own line via a full-width flex basis with the row allowed to wrap** — the approach first shipped on this branch and rejected here. It fixes the geometry (the row reports zero overflow from 1680px down to 600px) but at a visible cost: the branch action and the end clock wrap below the editor while it is open, the row occupies three lines, and the interaction competes for the same horizontal strip the row already fills. That cost is what [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) reported from real use — the row reads as misaligned once the editor expands — and it asked for the popover the chat surface already uses. A popover removes the editor from the row entirely, so the strip and the keyboard tab order are untouched whether the editor is open or not. + +**An absolutely-positioned popover not portaled out of the column** — rejected: the conversation column is an `overflow-y: auto` scroller, so a panel laid out inside it is clipped at the scroll edge and does not track the message as the column scrolls. Portaling to `document.body` with fixed placement from the trigger rect is what makes the floating panel viable, exactly as `Menu`'s portal mode and the subagent catalog popover already do. + +**A new `belowActions` seam on `MessageIconActions`, rendering the editor as a sibling under the row** — rejected: the slot contract documents `assistant-actions` as rendering *inside* the message's IconActions row, and one entry cannot supply two render sites without widening the host contract for a presentation detail that a portaled popover already expresses without touching the host. + +## Consequences + +With the editor open the actions row stays a single 28px line with zero overflow and nothing outside the column at every viewport from 1680px down to 600px — because the editor is not in the row to begin with. The panel floats above the transcript inside the viewport and stays anchored to its trigger, escaping the column's overflow clip. The editor is legible as an input in both themes. + +`apps/web/tests/message-feedback-layout.e2e.ts` sweeps six viewports with the editor open and pins, per stop, that the row reports one line and zero overflow, that the panel is outside the conversation column (proof it escapes the clip), that it lies within the viewport (proof the clamp holds), and that it sits by its trigger. A committed golden records the relations; reverting to inline (or dropping the portal) fails the geometry assertions. `packages/client/ui-message-feedback/tests/styles.client.spec.ts` checks the tokens against the theme's committed source, that the panel is `position: fixed`, and that it carries no flex sizing (so it cannot rejoin the row), plus the brace balance, following the `ui-settings-models` styles-spec precedent. The unit spec covers rate, note, reload, retract, plus the popover's portal-to-body, Escape/outside-click dismissal, and keep-open-on-inside-click. + +The `ui-message-feedback` package adds `@types/react-dom` so the `createPortal` usage typechecks, mirroring `ui-primitives`. + +Known limitations are accepted rather than fixed here. A rating click while the panel is open closes it, and the close path returns focus to the note trigger rather than leaving it on the rating button the human just pressed; the same happens when an outside click lands on another focusable control, which the browser focuses before the close returns focus to the trigger. A pointer user does not notice either; a keyboard user feels the focus move. The clamp assumes the panel fits: a panel taller than the viewport makes the upper bound `innerHeight - height - margin` smaller than `margin`, so `top` goes negative and the panel's head is cut off rather than its foot. The panel's three-row textarea carries `resize: vertical`, so a human can drag past that size; `.notePanel` therefore bounds its height at `calc(100vh - 24px)` and scrolls its own content, the counterpart of the existing `max-width` and the same 12px margin the clamp uses. If the rating disappears while the editor is open, the panel unmounts on the `rating !== undefined` guard but `noteOpen` stays true, so the document-level Escape and pointer-down listeners remain attached; should the item reappear through a later resync, the panel returns with the previous draft and without refocusing the textarea. The window is one click or Escape wide, and the save failure it could hide already falls back to the row, so it is left as it is. A failure that lands after the panel was closed and reopened is not written into the new session's panel: its draft was reseeded from the stored note, so an old attempt's error would mislabel it, and the uncommitted content is already gone — the failure is dropped rather than shown. And while the placement replays on scroll, window resize, and the panel's own size changes, jsdom has no layout, so the real geometry is proven by the browser scenario while the unit spec covers the wiring through a `ResizeObserver` stub. + +A residual narrow-viewport clock overflow remains below 520px from the clock string alone, unrelated to the feedback surface. The repo has no gate for undefined design tokens, and a scan during this work found more in `ui-agent-preset`, `ui-conversation`, `ui-jobs`, `ui-settings-plugins`, and `ui-tool`; they are untouched here and want their own change. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md new file mode 100644 index 0000000000..b130ab1e9c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md @@ -0,0 +1,43 @@ +# Agent Note:反馈备注编辑器以浮层悬浮在对话记录上方 + +Status: implemented + +[English](2026-08-13-feedback-note-editor-popover.md) | 中文 + +## Problem + +消息反馈的 Web 界面([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262))把控件贡献给 `conversation.chat.assistant-actions`,该槽位渲染在已定稿助手消息共享的 IconActions 行内。那一行是单条固定高度的 `flex` 线,`flex-wrap` 保持初始值 `nowrap` 且 `height: 28px`,按 28px 图标加一个时钟来定尺寸。备注编辑器作为一个内联组挂进去,内含 `width: 260px` 的 textarea 加 Save 与 Cancel。 + +一个 260px 输入框加两个按钮在任何窗口尺寸下都装不进那条线。对着已构建产物实测,编辑器打开时该行的可滚动溢出在 1680px 视口下是 168px,在 600px 下是 444px——这个缺陷从来不是窄窗口的边缘情况,在全屏桌面下就已存在。flex 溢出会溢出到线的末端之外,因此按 flex 顺序排在编辑器之后的项被挤出会话列:branch 操作在 600px 时离开列,时钟及其运行时长/TTFT/吞吐读数在 900px 时离开列。这些控件在不可见的同时仍可命中测试,所以没有任何行为断言发现它;已交付的 e2e 覆盖评分、备注、reload 与撤回,而 24 个 UI 快照是与宽度无关的 DOM。 + +同一张样式表还引用了四个主题并未定义的 `--dsw-alias-*` token:`border-secondary`、`bg-primary`、`interactive-bg-primary` 与 `label-inverse`。未定义的自定义属性会让其所在的整条声明在 computed-value 阶段失效,因此 textarea 交付时既无边框也无底色,Save 既无填充也无可读标签——编辑器读起来像是浮在对话记录里的散落文本,而不是一个输入框。 + +## Decision + +备注编辑器完全不进入行的 flex 布局。它是一个浮层:一张固定定位的面板,portal 到 `document.body`,其坐标来自备注触发按钮的矩形。行保持其单行图标与备注触发按钮,因此没有任何东西需要围绕编辑器收缩、换行或回流,任何地方都不需要 `order` 或换行。portal 出会话列也逃出了列的 `overflow` 裁剪,因此面板不会被滚动边缘裁掉,并且当对话记录滚动时会随它所批注的消息一起移动。这里复用 `ui-primitives/Menu` 为锚定菜单所用的同一套 portal 机制(`ui-subagent` 的 catalog popover 就构建在它之上):面板 `position: fixed`,打开时从 anchor rect 定位,钳制在视口内,并在滚动(捕获阶段)与缩放时重新定位。这套锚定逻辑是共享而非复制的:`ui-primitives/useAnchoredPosition` 持有「测量—偏移—钳制—跟随」这一件事,而促成这次抽取的正是重复代码门禁——内联的钳制与那对监听器被报为与 `Menu` 的 10 行克隆。`Menu` 保留自己的 effect,因为它的定位还要解析 `side`/`align` 变体与可选的调用方 anchor rect,而本界面不需要这些;该 hook 覆盖的是两边本来都要各写一遍的「锚点正下方」这一简单情形。 + +**操作条。** 点赞/点踩按钮与备注触发按钮保持原样留在行内。触发按钮是普通 `button`(`aria-haspopup="dialog"`,打开时 `aria-expanded`),在没有备注时显示「补充说明」,已有备注时显示备注文本。 + +**浮层。** 打开时,面板内含 textarea、Save 与 Cancel,以及任何备注保存失败提示,作为 `role="dialog"`,其标题与 textarea 自身的标签不同,以便两者都能按名称寻址。它在触发按钮下方打开(4px 间距),钳制到距视口边缘 12px,自动聚焦 textarea,并在 Escape 或外部 pointer-down 时关闭。关闭时仅当面板确实曾经打开才把焦点还给触发按钮,绝不会在初始挂载时(新渲染出的一条已评分消息不得把焦点拉进其操作条)。编辑器打开时进行评分操作会关闭面板。四个未定义 token 换成主题确实定义的那些,与 primitives 的既有做法一致:输入框用 `border-l2` 与 `bg-layer-1`,Save 用 `button-primary-fill` 配 `label-primary-foreground` 并加 `button-primary-hover` 状态;面板表面复用 Menu 卡片的配方(`--dsw-specific-menu`、`--dsw-shadow-lv3`、反色发丝线 `--dsw-alias-border-inverted`、`border-radius: 12px`)。 + +**失败提示按人的视线所落之处拆分。** 评分或列表加载失败显示在按钮旁的图标行里,无论浮层是否打开都清晰可读。备注保存失败显示在浮层内、Save/Cancel 旁,且面板保持打开,以便草稿留存待修正。 + +## Alternatives considered + +**行内展开:编辑器通过整行 flex basis 独占一行,并让行允许换行** — 这是本分支最初交付、在此否决的做法。它修好了几何(行在 1680px 到 600px 报告零溢出),但有可见代价:branch 与末尾时钟在编辑器打开时换行到编辑器下方,行占三行,交互与行本就占满的横向条带争空间。这一代价正是 [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) 在真实使用中反馈的问题——编辑器展开后这一行读起来是错位的——并提出改用 chat 界面已有的弹窗。浮层把编辑器完全移出行,因此无论编辑器是否打开,操作条与键盘 Tab 顺序都不受影响。 + +**不 portal 出列的绝对定位浮层** — 否决:会话列是 `overflow-y: auto` 的滚动容器,因此在列内布局的面板会被滚动边缘裁掉,且不随列滚动而跟住消息。portal 到 `document.body` 并从触发按钮矩形做固定定位,才让浮动面板可行,正如 `Menu` 的 portal 模式与 subagent catalog popover 已然做到的那样。 + +**在 `MessageIconActions` 上新增 `belowActions` 接缝,把编辑器作为该行的兄弟节点渲染在下方** — 否决:slot 契约明确记载 `assistant-actions` 渲染在消息 IconActions 行**内部**,且单个条目无法在不为一个展示细节拓宽 Host 契约的前提下提供两个渲染点,而 portal 出的浮层无需触碰 Host 就表达了该细节。 + +## Consequences + +编辑器打开时,操作行保持单条 28px 线,在 1680px 到 600px 的每一档视口都零溢出、零项落在列外——因为编辑器本就不在行里。面板悬浮于对话记录之上、位于视口内,并保持锚定其触发按钮,逃出列溢出裁剪。编辑器在两种主题下都能被辨认为输入框。 + +`apps/web/tests/message-feedback-layout.e2e.ts` 在编辑器打开时扫描六个视口,并在每一档钉住:行报告单行零溢出、面板位于会话列之外(证明它逃出裁剪)、面板落在视口内(证明钳制有效)、面板紧贴其触发按钮。已提交的 golden 记录这些关系;回退到行内(或去掉 portal)会让几何断言失败。`packages/client/ui-message-feedback/tests/styles.client.spec.ts` 校验 token 与主题已提交的源一致、面板为 `position: fixed`、且不带任何 flex sizing(因此不会重新加入行),并校验大括号平衡,沿用 `ui-settings-models` styles spec 的先例。单元 spec 覆盖评分、备注、reload、撤回,外加浮层的 portal 到 body、Escape/外部点击关闭、以及浮层内部点击保持打开。 + +`ui-message-feedback` 包新增 `@types/react-dom`,使 `createPortal` 用法能通过类型检查,与 `ui-primitives` 一致。 + +有若干已知限制在此接受而非修复。面板打开时点击评分会关闭它,而关闭路径把焦点归还给备注触发按钮,而不是留在用户刚按下的评分按钮上;外部点击落在另一个可聚焦控件上时同理——浏览器先把焦点给该控件,随后关闭路径又把它拉回触发按钮。指针用户对两者都无感,键盘用户会察觉焦点移动。钳制假定面板放得下:面板高于视口时,上界 `innerHeight - height - margin` 会小于 `margin`,于是 `top` 变为负值、被裁掉的是面板顶部而非底部。面板里的三行 textarea 带 `resize: vertical`,用户可以拖过这个尺寸,因此 `.notePanel` 把自身高度限制在 `calc(100vh - 24px)` 并自行滚动内容——这是既有 `max-width` 的对应项,用的是与钳制相同的 12px 边距。编辑器打开时若评分消失,面板会因 `rating !== undefined` 守卫卸载,但 `noteOpen` 仍为 true,因此 document 级的 Escape 与 pointer-down 监听继续挂着;若该 item 之后经 resync 重新出现,浮层会带着上一次的草稿回来且不重新聚焦 textarea。该窗口只有一次点击或一次 Escape 那么宽,而它可能遮住的保存失败已经有行内回退,因此保持现状。若失败在面板关闭并重开后才到达,不会写入新会话的面板:其草稿已按已存备注重新播种,旧尝试的错误会误标新草稿,而未提交的内容本就不存在——该失败被丢弃而不展示。以及,定位虽然会在滚动、窗口缩放与面板自身尺寸变化时重放,但 jsdom 没有布局,因此真实几何由浏览器场景证明,单测则通过 `ResizeObserver` stub 覆盖其接线。 + +520px 以下仍残留仅来自时钟字符串的窄视口溢出,与本界面无关。仓库没有针对未定义设计 token 的门禁;本次工作中的一次扫描在 `ui-agent-preset`、`ui-conversation`、`ui-jobs`、`ui-settings-plugins` 与 `ui-tool` 中又发现更多,本次未触碰,需要单独的改动处理。 diff --git a/apps/web/tests/message-feedback-layout.e2e.ts b/apps/web/tests/message-feedback-layout.e2e.ts new file mode 100644 index 0000000000..89f20eee79 --- /dev/null +++ b/apps/web/tests/message-feedback-layout.e2e.ts @@ -0,0 +1,336 @@ +// Web e2e scenario: with the feedback note editor open, the assistant IconActions +// row stays one intact line (no wrapping, nothing pushed out), and the note +// editor floats above the transcript in a popover that escapes the conversation +// column's overflow clip and stays inside the viewport. +// +// The hazard this pins: a slot-contributed note editor (260px textarea plus +// Save and Cancel) cannot fit the shared IconActions row at ANY viewport, and an +// inline expansion made the row wider than the column — full-screen desktop +// included — so the branch action and the clock were pushed out of view by later +// flex items. The fix is to not mount the editor in the row at all: it is a +// popover portaled to document.body and fixed-positioned from the note trigger's +// rect, so the row keeps its single 28px line of icons and the trigger, and the +// panel cannot be cropped by the column's overflow because it lives outside it. +// +// The sweep records, per viewport, whether the open editor keeps the actions row +// on one line with zero overflow, whether the panel is outside the column (proof +// it escapes the clip), whether the panel stays inside the viewport (proof the +// clamp works), and whether it sits by its trigger. All relations, no absolute +// pixels: the column width follows the viewport, the sidebar, and the platform's +// scrollbar, so a golden carrying pixels would document the platform, not the +// behavior. +// +// Zero model calls: a settled transcript is cold-seeded, so nothing streams. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-layout', import.meta.url)) +/** + * Committed golden of the popover relations at every stop. Booleans and counts + * only, never absolute coordinates. + */ +const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') +const MODE = webSnapshotMode() +/** Borrowed read-only: this scenario needs any settled assistant message to rate. */ +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const SEED_ID = 'message-feedback-layout-e2e' +/** Viewport widths from full-screen desktop down to a narrow window. */ +const WIDTHS = [1680, 1280, 1024, 900, 700, 600] + +/** One viewport stop: how the row reads with the note editor closed and open, plus the popover's own relations. */ +export interface PopoverMetrics { + /** Viewport width the stop was measured at. */ + width: number + /** The row's scrollable overflow with the note editor closed (natural row width). */ + rowOverflowClosed: number + /** The row's scrollable overflow with the note editor open; must equal the closed value. */ + rowOverflowOpen: number + /** Flex lines the row occupies with the note editor open; the editor must not reflow it. */ + rowLines: number + /** Row items whose right edge escapes the column, editor closed. */ + itemsOutsideColumnClosed: number + /** Row items whose right edge escapes the column, editor open; must equal the closed value. */ + itemsOutsideColumnOpen: number + /** True when the portaled panel is NOT inside the column (escapes its overflow clip). */ + panelOutsideColumn: boolean + /** True when the panel lies fully inside the viewport (the clamp holds). */ + panelWithinViewport: boolean + /** Horizontal separation between the panel's left edge and the note trigger's, in px. */ + panelToTriggerGap: number +} + +/** + * Measure the feedback row (and the open popover, when present) at the current + * viewport. The same reader serves the closed and open readings so the two + * sides differ only by whether the editor is open. + * @param page - the page under test. + * @param width - the viewport width already applied, recorded with the reading. + * @param editorOpen - true to also read the popover's relations; throws if it is absent. + * @returns the stop's relations. + */ +function measurePopover(page: Page, width: number, editorOpen: boolean): Promise { + return page.evaluate(({ viewportWidth, open }) => { + const rated = document.querySelector('button[aria-label="Remove rating"]') + if (rated === null) throw new Error('no rated feedback control in the DOM') + const row = rated.parentElement?.closest('div[class*="actions"]') ?? null + if (row === null) throw new Error('the IconActions row is not an ancestor of the feedback control') + const trigger = row.querySelector('button[aria-haspopup="dialog"]') + if (trigger === null) throw new Error('the note trigger is not in the row') + + /** + * The real flex items of the row. A slot contributor (the feedback strip) + * arrives as a `display: contents` wrapper (the `assistant-actions` slot + * renders inside a transparent `data-slot` div), which reports an all-zero + * rect; a zero box would be miscounted as a phantom flex line. The actual + * items are the boxes inside it. + * @param element - the row whose items to read. + * @returns the real flex-item boxes, in flex/DOM order. + */ + const flexItemBoxes = (element: HTMLElement): DOMRect[] => { + const boxes: DOMRect[] = [] + for (const child of Array.from(element.children)) { + const el = child as HTMLElement + const rect = el.getBoundingClientRect() + if (el.style.display === 'contents') { + boxes.push(...flexItemBoxes(el)) + } else if (rect.height > 0 && rect.width > 0) { + boxes.push(rect) + } + } + return boxes + } + /** + * Group items into flex lines by overlapping vertical extent. + * @param boxes - the row items' boxes, in DOM order. + * @returns the number of distinct lines. + */ + const countFlexLines = (boxes: DOMRect[]): number => { + const centres: number[] = [] + for (const box of boxes) { + const centre = box.top + box.height / 2 + if (!centres.some(known => Math.abs(known - centre) <= box.height / 2)) centres.push(centre) + } + return centres.length + } + + const column = row.closest('[data-conversation-scroll]') + const columnRight = (column?.getBoundingClientRect().left ?? 0) + (column?.clientWidth ?? 0) + const itemRects = flexItemBoxes(row) + // A half-pixel tolerance: subpixel layout puts a contained edge a fraction + // over the boundary on some device scale factors. + const itemsOutsideColumn = itemRects.filter(box => box.right > columnRight + 0.5).length + // The editor is a portal, so the row measures identically whether the + // editor is open or not; the closed/open fields differ by call so the sweep + // can assert a zero delta on them. + const overflow = row.scrollWidth - row.clientWidth + + let builder: { + panelOutsideColumn: boolean + panelWithinViewport: boolean + panelToTriggerGap: number + } + if (!open) { + builder = { panelOutsideColumn: true, panelWithinViewport: true, panelToTriggerGap: 0 } + } else { + const panel = document.body.querySelector('[role="dialog"]') + if (panel === null) throw new Error('the note popover is not open') + const panelBox = panel.getBoundingClientRect() + const triggerBox = trigger.getBoundingClientRect() + const vw = window.innerWidth + const vh = window.innerHeight + builder = { + // The panel portals out of the column, so the clip cannot reach it. + panelOutsideColumn: column === null ? true : !column.contains(panel), + panelWithinViewport: + panelBox.left >= -0.5 + && panelBox.right <= vw + 0.5 + && panelBox.top >= -0.5 + && panelBox.bottom <= vh + 0.5, + // The panel is fixed from the trigger's left, so a zero gap says it is + // anchored; a clamp can only widen it. + panelToTriggerGap: Math.abs(panelBox.left - triggerBox.left), + } + } + + return { + width: viewportWidth, + rowOverflowClosed: overflow, + rowOverflowOpen: overflow, + rowLines: countFlexLines(itemRects), + itemsOutsideColumnClosed: itemsOutsideColumn, + itemsOutsideColumnOpen: itemsOutsideColumn, + ...builder, + } + }, { viewportWidth: width, open: editorOpen }) +} + +/** + * Render the golden body: one line per stop, relations and counts only. The + * row-overflow and outside-column readings are deltas (open minus closed) so + * the golden records that opening the editor leaves the row untouched, not an + * absolute count that many unrelated controls could move. + * @param stops - the measured stops, in sweep order. + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(stops: PopoverMetrics[]): string { + return [ + '# Assistant actions row with the feedback note popover open', + '', + '| viewport | row overflow delta | row lines | items-outside delta ' + + '| panel outside the column | panel within the viewport | panel-to-trigger gap |', + '| --- | --- | --- | --- | --- | --- | --- |', + ...stops.map(stop => `| ${String(stop.width)}px | ${String(stop.rowOverflowOpen - stop.rowOverflowClosed)}px ` + + `| ${String(stop.rowLines)} | ${String(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed)} ` + + `| ${String(stop.panelOutsideColumn)} | ${String(stop.panelWithinViewport)} ` + + `| ${String(stop.panelToTriggerGap)}px |`), + ].join('\n') +} + +describe('web e2e: the feedback note editor floats above the column', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser, 900) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** + * Open the seeded transcript. The first treeitem is the collapsible group + * row; the session itself is the row beneath it. + * @returns nothing. + */ + async function openSeededSession(): Promise { + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 15_000 }) + await sessionRow.click() + } + + /** + * Resize to a viewport and read the row once its width stops moving. The + * frame eases its column tracks, so reading straight after a resize can + * report the previous viewport's relation. + * @param width - viewport width to settle at. + * @param editorOpen - whether the note editor is currently open; reads the popover relations when so. + * @returns the row's (and popover's) readings at that width. + */ + const settleAt = async (width: number, editorOpen: boolean): Promise => { + await page.setViewportSize({ width, height: 900 }) + let previous = -1 + await expect.poll(async () => { + const current = await page.evaluate(() => + document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1) + const settled = current === previous + previous = current + return settled + }, { timeout: 10_000 }).toBe(true) + // The popover is JS-positioned from the trigger rect and re-places on + // resize/scroll, so once the column width stops moving we nudge it to the + // final layout; otherwise the panel can sit at a transient position from + // mid-resize and the anchor reading would be off. + await page.evaluate(() => window.dispatchEvent(new Event('resize'))) + return measurePopover(page, width, editorOpen) + } + + /** + * Rate a message, then for every stop read the row once with the note editor + * closed and once with it open, handing the SAME measured readings to both + * assertions so the golden and the assertions describe one measurement + * rather than two runs that could disagree. + * @returns the stops in {@link WIDTHS} order. + */ + let swept: Promise | undefined + const sweep = (): Promise => { + swept ??= (async () => { + await openSeededSession() + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) + // The controller defers its list read to the first hover or focus, so the + // strip has to be touched before it can be rated. + const like = page.getByRole('button', { name: 'Good response' }).first() + await like.waitFor({ timeout: 30_000 }) + await like.scrollIntoViewIfNeeded() + await like.hover() + await like.click() + await page.getByRole('button', { name: 'Remove rating' }).first() + .waitFor({ timeout: 15_000 }) + const noteTrigger = page.getByRole('button', { name: 'Add a note' }).first() + const stops: PopoverMetrics[] = [] + for (const width of WIDTHS) { + // Reset to the closed baseline at each stop before opening. + if (await noteTrigger.getAttribute('aria-expanded') === 'true') await noteTrigger.click() + const closed = await settleAt(width, false) + await page.getByRole('button', { name: 'Add a note' }).first().click() + await page.getByRole('dialog').waitFor({ timeout: 10_000 }) + const open = await settleAt(width, true) + stops.push({ + width, + rowOverflowClosed: closed.rowOverflowClosed, + rowOverflowOpen: open.rowOverflowOpen, + rowLines: open.rowLines, + itemsOutsideColumnClosed: closed.itemsOutsideColumnClosed, + itemsOutsideColumnOpen: open.itemsOutsideColumnOpen, + panelOutsideColumn: open.panelOutsideColumn, + panelWithinViewport: open.panelWithinViewport, + panelToTriggerGap: open.panelToTriggerGap, + }) + } + return stops + })() + return swept + } + + it('keeps the actions row untouched by the note popover, which stays in the viewport', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout')) + const stops = await sweep() + for (const stop of stops) { + // The popover lives outside the row, so opening it must not change the + // row at all. This is the vacuity guard of the whole redesign: an inline + // editor would widen or reflow the row, pushing the delta off zero. + expect(stop.rowOverflowOpen - stop.rowOverflowClosed, `viewport ${String(stop.width)}`).toBe(0) + expect(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed, `viewport ${String(stop.width)}`).toBe(0) + // The row is one 28px line; the editor never forces a reflow. + expect(stop.rowLines, `viewport ${String(stop.width)}`).toBe(1) + // The panel escapes the column's overflow clip by living outside it. + expect(stop.panelOutsideColumn, `viewport ${String(stop.width)}`).toBe(true) + // The placement clamps the panel inside the viewport at every width. + expect(stop.panelWithinViewport, `viewport ${String(stop.width)}`).toBe(true) + // The panel stays anchored to its trigger rather than drifting off. + expect(stop.panelToTriggerGap, `viewport ${String(stop.width)}`).toBeLessThanOrEqual(4) + } + expect(tripwire.pageErrors).toEqual([]) + }, 180_000) + + it('matches the committed geometry golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout-golden')) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE) + }, 180_000) + + it('kept the console clean', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md b/apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md new file mode 100644 index 0000000000..7780a8057a --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md @@ -0,0 +1,10 @@ +# Assistant actions row with the feedback note popover open + +| viewport | row overflow delta | row lines | items-outside delta | panel outside the column | panel within the viewport | panel-to-trigger gap | +| --- | --- | --- | --- | --- | --- | --- | +| 1680px | 0px | 1 | 0 | true | true | 0px | +| 1280px | 0px | 1 | 0 | true | true | 0px | +| 1024px | 0px | 1 | 0 | true | true | 0px | +| 900px | 0px | 1 | 0 | true | true | 0px | +| 700px | 0px | 1 | 0 | true | true | 0px | +| 600px | 0px | 1 | 0 | true | true | 0px | diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 6e706c7123..c76eb8aae6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -58,6 +58,7 @@ "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/message-feedback.e2e.ts", + "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index 9c05c4820f..59e5ac81f9 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-message-feedback/README.md -README.md: 461e87589567eb95b075839d5893cc551e67a035 -README.zh.md: 31f722021ff65f476a6ba1ab0211fd4e091671d2 +README.md: d3bb4e28b95da2fde0d26b7ef83ebca377b4939f +README.zh.md: d823bb9de2b7d39d0bc5d00164b8ce8dffec4a34 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index 461e875895..d3bb4e28b9 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets. +Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. The note editor itself does not sit in that row: it is a `role="dialog"` popover portaled to `document.body` and anchored under its trigger, so the row keeps its single line whether the editor is open or closed and the panel is not clipped by the conversation column. A rating or list-load failure shows inline in the row; a note-save failure shows inside the popover, which stays open so the draft can be corrected. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets. One `MessageFeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript. The read is deferred to the first hover or focus rather than fired on mount, because the controls mount once per settled message in the visible history. diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index 31f722021f..d823bb9de2 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。 +单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。备注编辑器本身不在这一行里:它是一个 `role="dialog"` 的浮层,portal 到 `document.body` 并锚定在其触发按钮下方,因此无论编辑器是否打开该行都保持单行,面板也不会被会话列裁掉。评分或列表加载失败在行内展示;备注保存失败在浮层内展示,且面板保持打开以便修正草稿。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。 每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话。该读取延迟到首次 hover 或 focus 才发起,而不是在挂载时触发,因为可见历史中每条已结束的消息都会挂载一次控件。 diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 2b23b7ccc4..032425e34b 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -71,6 +71,7 @@ "@deepseek-ai/cordis": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css index 8f554f2a3b..45055ad559 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css @@ -1,6 +1,9 @@ /* Per-message feedback controls. The rating buttons mirror the shared message - IconActions chrome so the strip reads as one row; the note editor is an - inline expansion anchored to the same row. */ + IconActions chrome so the strip reads as one row. The note editor is a + popover portaled to document.body and fixed from the note trigger's rect, + so it neither competes with the row for inline width nor gets cropped by the + conversation column's overflow clip. Surface recipe follows the Menu card: + r12, inverted hairline border, shadow-lv3. */ .action { display: inline-flex; @@ -47,29 +50,58 @@ cursor: pointer; } -.noteOpen:hover { +.noteOpen:hover, +.noteOpen[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } -.noteEditor { - display: inline-flex; - align-items: flex-start; - gap: 6px; +/* Portal surface: fixed in the viewport, left/top supplied inline from the + trigger rect. Portaled panels must layer above modal overlays (z 1000). */ +.notePanel { + position: fixed; + z-index: 1100; + box-sizing: border-box; + width: 320px; + max-width: min(360px, calc(100vw - 24px)); + /* The width bound's counterpart. `resize: vertical` on the textarea lets the + panel be dragged taller, and a panel taller than the viewport would push + the placement clamp's upper bound below its own margin, so `top` would go + negative and cut off the panel's head. Both bounds keep the 12px margin + the clamp uses. */ + max-height: calc(100vh - 24px); + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } .noteInput { - width: 260px; + width: 100%; + box-sizing: border-box; padding: 6px 8px; - border: 1px solid var(--dsw-alias-border-secondary); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; - background: var(--dsw-alias-bg-primary); + background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; resize: vertical; } +.noteActions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + .noteSave, .noteCancel { height: 28px; @@ -81,8 +113,12 @@ } .noteSave { - background: var(--dsw-alias-interactive-bg-primary); - color: var(--dsw-alias-label-inverse); + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); +} + +.noteSave:hover:not(:disabled) { + background: var(--dsw-alias-button-primary-hover); } .noteSave:disabled { @@ -104,5 +140,5 @@ padding-left: 4px; color: var(--dsw-alias-label-tertiary); font-size: 13px; - line-height: 28px; + line-height: 20px; } diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx index 779f76b12a..db7d50569b 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx @@ -1,23 +1,47 @@ /** * Per-message feedback controls: a Like/Dislike pair plus an optional note. - * Rendered inside the assistant message's IconActions row, so the buttons - * reuse that row's chrome and sit between copy and branch. + * The buttons render inside the assistant message's IconActions row, so they + * reuse that row's chrome and sit between copy and branch. The note editor is + * a popover (portaled to `document.body`) anchored to the note trigger, not an + * inline expansion: a 260px textarea plus buttons cannot fit the row at any + * viewport, and an inline element pushed the branch action and clock out of the + * conversation column. Portaling out of the column also escapes its `overflow` + * clip, so the panel cannot be cropped or detached from the message it annotates. * @module @deepseek-ai/dsh-client-ui-message-feedback/client/MessageFeedbackActions */ -import { useCallback, useEffect, useRef, useState } from 'react' import { - IconDislikeOutline16, IconLikeOutline16, Tooltip, + useCallback, useEffect, useRef, useState, + type CSSProperties, +} from 'react' +import { createPortal } from 'react-dom' +import { + IconDislikeOutline16, IconLikeOutline16, Tooltip, useAnchoredPosition, } from '@deepseek-ai/dsh-client-ui-primitives' import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types' import type { MessageFeedbackActionProps } from './slots.ts' import css from './MessageFeedbackActions.module.css' +/** Safe distance kept between the panel and the viewport edges (the Menu portal margin). */ +const PANEL_MARGIN = 12 + +/** Distance between the trigger's bottom edge and the panel's top. */ +const PANEL_GAP = 4 + +/** + * Unplaced portal panel: hidden but laid out so `offsetWidth` is real for the + * clamp. The explicit insets match `Menu`'s measure style — a `position: fixed` + * element with auto insets otherwise sits at its static position, a different + * origin than the one the first placement measures from. + */ +const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } + /** * One message's feedback controls. * @param props - the owner's message identity, the injected verbs, and the * shared feedback hook. - * @returns the rating buttons, plus the note editor while it is open. + * @returns the rating buttons and the note trigger, with the note editor + * portal-open beneath the trigger while it is open. */ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearNote, useFeedback, t }: MessageFeedbackActionProps) { const item = useFeedback(view => view.items.get(messageId)) @@ -26,7 +50,15 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN const [noteOpen, setNoteOpen] = useState(false) const [draft, setDraft] = useState('') const [pending, setPending] = useState(false) - const [failure, setFailure] = useState(null) + // A rating or load failure surfaces beside the rating buttons, always legible + // whether or not the note popover is open. + const [rowFailure, setRowFailure] = useState(null) + // A note save failure surfaces inside the note popover, where the human is + // looking; it stays open so the draft survives to be corrected. + const [noteFailure, setNoteFailure] = useState(null) + const triggerRef = useRef(null) + const panelRef = useRef(null) + const inputRef = useRef(null) // The controls mount for every settled message in the transcript, so the // Session's feedback is read once on first hover/focus rather than on mount. const seeded = useRef(false) @@ -39,47 +71,158 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN const alive = useRef(true) useEffect(() => () => { alive.current = false }, []) - const settle = useCallback((result: { ok: boolean; error?: { code: string } }) => { + /** Bumped whenever an editing session ends, so a late save can tell it is stale. */ + const noteGeneration = useRef(0) + + /** Current panel open-state, readable from a stale closure via a ref. */ + const noteOpenRef = useRef(false) + useEffect(() => { noteOpenRef.current = noteOpen }, [noteOpen]) + + const errorCopy = useCallback((result: { ok: boolean; error?: { code: string } }) => { + return result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic') + }, [t]) + + const settleRating = useCallback((result: { ok: boolean; error?: { code: string } }) => { if (!alive.current) return setPending(false) - if (result.ok) { - setFailure(null) - return - } - setFailure(result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic')) - }, [t]) + setRowFailure(result.ok ? null : errorCopy(result)) + }, [errorCopy]) + + const closeNote = useCallback(() => { + // Ends the editing session, so any save still in flight becomes stale. + noteGeneration.current += 1 + setNoteOpen(false) + }, []) const onRate = useCallback((next: MessageFeedbackRating) => { setPending(true) - setFailure(null) + setRowFailure(null) // The controller decides retract-vs-replace from the committed item, so a // click that lands before the first list read still toggles the stored // value instead of this render's empty view. - setNoteOpen(false) - void toggle(messageId, next).then(settle) - }, [messageId, settle, toggle]) + closeNote() + void toggle(messageId, next).then(settleRating) + }, [closeNote, messageId, settleRating, toggle]) // The rating is a parameter because only the note editor's render site can // prove one is recorded; that removes an unreachable undefined guard here. const onSaveNote = useCallback((current: MessageFeedbackRating) => { const trimmed = draft.trim() setPending(true) - setFailure(null) + setNoteFailure(null) + // A save belongs to the editing session that started it. Closing and + // reopening the panel begins a new one, and a late reply from the old + // session must not act on it: a stale success would shut the panel the + // human just opened, and a stale failure would describe a draft this + // session never sent. + const generation = noteGeneration.current + // What a session reopened before this save commits would be seeded with. + const staleSeed = item?.note ?? '' // An emptied editor removes the note explicitly; `rate` alone preserves a // stored note, so it cannot express deletion. const settled = trimmed.length === 0 ? clearNote(messageId) : rate(messageId, current, trimmed) void settled.then((result) => { - settle(result) - if (result.ok && alive.current) setNoteOpen(false) + if (!alive.current) return + // `pending` tracks the request in flight, not the editing session, so it + // is released either way; all three of like, dislike and Save read + // `disabled={pending}`, and holding it would lock the row until remount. + // Releasing it unconditionally is safe because those three are the only + // mutation entries and each is gated by it, so at most one request is ever + // in flight. A future entry that bypasses the gate would have to bind + // `pending` to the generation instead of clearing it here. + setPending(false) + if (result.ok) { + // Only the session that is still open may act on a success: closing it + // already discarded the draft, and reopening seeded a new one. + if (generation === noteGeneration.current) { + setNoteFailure(null) + setNoteOpen(false) + return + } + // A newer session is open, seeded from the note as it read before this + // save committed. Resync it so the editor shows what is stored and the + // next save cannot overwrite the text that just landed. An edited draft + // is the human's, so it is left alone. + setDraft(draftNow => (draftNow === staleSeed ? trimmed : draftNow)) + return + } + // A failure from the session still on screen belongs in its panel. One + // from an abandoned session is reported only when no new session has + // taken over: the row then carries it, so a save that failed after the + // human walked away is not silently dropped. Writing it into a reopened + // panel instead would label the new draft with the old attempt's error. + // `noteOpenRef` — not the `noteOpen` this closure was created from — is + // read here, because a close+reopen between the save and resolution + // leaves this closure with the panel state from when the save started. + if (generation === noteGeneration.current || !noteOpenRef.current) { + setNoteFailure(errorCopy(result)) + } }) - }, [clearNote, draft, messageId, rate, settle]) + }, [clearNote, draft, errorCopy, item?.note, messageId, noteOpenRef, rate]) - const openNote = useCallback(() => { + // The trigger toggles: while closed it opens the popover (seeding the draft + // with the recorded note), while open it closes it. Toggling closed via the + // trigger also fires the outside/within logic correctly because the trigger + // is inside the panel's "inside" region. + const toggleNote = useCallback(() => { + if (noteOpen) { + closeNote() + return + } setDraft(item?.note ?? '') + // A note-save failure belongs to the editing session that produced it. The + // panel stays open on failure so the draft can be corrected, but once it is + // closed and reopened the draft is reseeded from the stored note, so a + // carried-over error would describe an attempt the new draft never made. + // A failure that arrives after the panel closed is reported in the row, and + // clearing it here is what retires that notice when a new session starts. + setNoteFailure(null) setNoteOpen(true) - }, [item?.note]) + }, [noteOpen, closeNote, item?.note]) + + // Place the portaled panel from the trigger rect before paint and keep it + // with the trigger on scroll/resize, the same anchoring `Menu` uses for its + // portal mode. + const pos = useAnchoredPosition({ + open: noteOpen, + anchorRef: triggerRef, + panelRef, + gap: PANEL_GAP, + margin: PANEL_MARGIN, + }) + + // Focus the input and close on Escape or outside pointer-down while open. + useEffect(() => { + if (!noteOpen) return + inputRef.current?.focus() + const onPointerDown = (e: PointerEvent) => { + if (!(e.target instanceof Node)) return + if (triggerRef.current?.contains(e.target) === true) return + if (panelRef.current?.contains(e.target) === true) return + closeNote() + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeNote() + } + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [noteOpen, closeNote]) + + // Return focus to the trigger only when the panel actually closes, not on the + // initial mount (a freshly rendered message with a recorded rating must not + // pull focus into its action row). + const wasOpen = useRef(false) + useEffect(() => { + if (noteOpen) { wasOpen.current = true; return } + if (wasOpen.current) triggerRef.current?.focus() + wasOpen.current = false + }, [noteOpen]) const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like') const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike') @@ -116,38 +259,66 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN - {rating !== undefined && !noteOpen && ( - )} - {rating !== undefined && noteOpen && ( - + {rowFailure === null && loadFailed && ( + {t('error.load')} + )} + {rowFailure !== null && {rowFailure}} + {/* A note-save failure normally lives inside the panel, beside the buttons + that produced it. Whenever the panel is not on screen it falls back to + the row instead: the rating may have disappeared underneath an open + editor (another client retracts the feedback, a `version-conflict` + reply commits `current: null`, the item goes away), or the human may + have closed the panel before a slow save came back. Either way the row + reports that the save did not land rather than dropping it. */} + {!(rating !== undefined && noteOpen) && noteFailure !== null && ( + {noteFailure} + )} + {rating !== undefined && noteOpen && createPortal( +