From 4e4b4eb9b6e67942b866a637f64a0e9165eb7aef Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:29 +0800 Subject: [PATCH 01/81] feat(fs): classify host errno failures as FsError dsh-fs gains fsErrorFromErrno, which classifies a host errno failure: ENOENT/ENOTDIR -> FS_NOT_FOUND, EACCES/EPERM -> FS_PERMISSION_DENIED, EISDIR -> FS_NOT_REGULAR_FILE, other errno -> FS_IO_ERROR, keeping the original error as cause. fs-local uses it for resolution, metadata, reads, and listing, and fs-sandbox for its containment check, so tool results persist error.info instead of a raw Node error. FsError assigns its literal name, and FS_ERROR_CODES gains FS_ALREADY_EXISTS for create-only commands. --- docs/subsystems/filesystem.i18n.yaml | 4 +- docs/subsystems/filesystem.md | 2 +- docs/subsystems/filesystem.zh.md | 2 +- packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/README.zh.md | 2 +- packages/fs/fs-local/src/fsio.ts | 59 ++++++++----------- packages/fs/fs-local/tests/fsio.spec.ts | 41 +++++++++++++ packages/fs/fs-sandbox/src/containment.ts | 6 +- .../fs/fs-sandbox/tests/containment.spec.ts | 8 +++ packages/fs/fs/README.i18n.yaml | 8 +-- packages/fs/fs/README.md | 3 +- packages/fs/fs/README.zh.md | 3 +- packages/fs/fs/src/errno.ts | 33 +++++++++++ packages/fs/fs/src/index.ts | 1 + packages/fs/fs/src/types.ts | 2 + packages/fs/fs/tests/errno.spec.ts | 30 ++++++++++ 17 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 packages/fs/fs/src/errno.ts create mode 100644 packages/fs/fs/tests/errno.spec.ts diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 03d12b8456..09a886a568 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.i18n.yaml @@ -30,8 +30,8 @@ en: 7b462db42ab89b10 zh: 968219ded2b55d69 /filesystem/error-taxonomy-provider-contract: - en: ca206569d4e2528d - zh: e36d70ad39a73570 + en: dbab88fbb60fcb8f + zh: f90be1352b1e05f8 /filesystem/no-timeouts-on-file-io: en: ae18af906f3f7e50 zh: 24a1aa6f903fe08d diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index fcbbec29cd..f19f09ab43 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -375,7 +375,7 @@ import { FS_ERROR_CODES } from '@deepseek-ai/dsh-fs' type FsErrorCode = typeof FS_ERROR_CODES[number] ``` -`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a mutation — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. +`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a mutation — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. `FS_ALREADY_EXISTS` means a create-only tool command found an existing target; the caller chooses another path or edits the existing file. `dsh-fs-local` and the `dsh-fs-sandbox` containment check classify errno failures from resolution, metadata, reads, and listing with the `dsh-fs` function `fsErrorFromErrno`: `ENOENT` and `ENOTDIR` report `FS_NOT_FOUND`, `EACCES` and `EPERM` report `FS_PERMISSION_DENIED`, `EISDIR` reports `FS_NOT_REGULAR_FILE`, and other errno values report `FS_IO_ERROR`, each with the Node error as `cause`. ## No timeouts on file IO diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 22f59a7884..8de900b9bd 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -375,7 +375,7 @@ import { FS_ERROR_CODES } from '@deepseek-ai/dsh-fs' type FsErrorCode = typeof FS_ERROR_CODES[number] ``` -目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了变更——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观测记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观测到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。 +目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了变更——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观测记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观测到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。`FS_ALREADY_EXISTS` 表示仅创建的工具命令遇到了已存在的目标;调用方应换一个路径或编辑现有文件。`dsh-fs-local` 与 `dsh-fs-sandbox` 的包含性检查用 `dsh-fs` 的函数 `fsErrorFromErrno` 分类解析、元数据、读取与目录列表中的 errno 失败:`ENOENT` 与 `ENOTDIR` 报告 `FS_NOT_FOUND`,`EACCES` 与 `EPERM` 报告 `FS_PERMISSION_DENIED`,`EISDIR` 报告 `FS_NOT_REGULAR_FILE`,其他 errno 报告 `FS_IO_ERROR`,均以 Node 错误为 `cause`。 ## 文件 IO 不设超时 diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index ca5f575769..4c9e8ec1ef 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/README.i18n.yaml @@ -24,8 +24,8 @@ en: ca0cfe83eca367c0 zh: 9d3a6d61952f180e /deepseek-ai-dsh-fs-local/use-this-package/what-you-can-do: - en: 04b6889eec28bd96 - zh: e94e2787de0ba704 + en: 0f05ec25037a6d39 + zh: d67d80a04c148f3b /deepseek-ai-dsh-fs-local/understand-the-implementation: en: 6ffc93e09381bd3d zh: 57c3192c26a080ef diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 132dc626e6..f2846682d7 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -52,7 +52,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Read regular UTF-8 files whole or as streams, read bounded bytes, and list one directory level. Writes, literal edits, patches, and deletions serialize per target. Moves lock both targets in lexical order. `patchText` checks the version before calling `applyHunks`; `deleteFile` removes only a regular file. Missing sources use `FS_STALE_VERSION`, unmatched hunks use `FS_PATCH_MISMATCH`, and identical move targets use `FS_SAME_TARGET`. -Patch and move text is LF-normalized internally and restored using the source line-ending style; outcome text uses LF. Delete bases and overwritten destination bases follow `diffBasisMaxBytes`. A move checks cancellation before staging the destination, then completes both effects; a source-removal failure returns the published destination with `sourceDeleted: false`. Atomic write failures map denied directory or staging creation to `FS_PERMISSION_DENIED` and other Node I/O errors to `FS_IO_ERROR`. +Patch and move text is LF-normalized internally and restored using the source line-ending style; outcome text uses LF. Delete bases and overwritten destination bases follow `diffBasisMaxBytes`. A move checks cancellation before staging the destination, then completes both effects; a source-removal failure returns the published destination with `sourceDeleted: false`. Atomic write failures map denied directory or staging creation to `FS_PERMISSION_DENIED` and other Node I/O errors to `FS_IO_ERROR`. Resolution, metadata, read, and listing failures pass through `fsErrorFromErrno` from `dsh-fs`, so every errno becomes an `FsError` code; a thrown value without an errno code is a defect and propagates unchanged. Read, listing, and mutation failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on an edit reports `FS_STALE_VERSION` whether or not the version guard is supplied. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index edbf6a7da1..d1abde6841 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -52,7 +52,7 @@ kind: "package-reference" 完整或流式读取普通 UTF-8 文件、读取有界字节,并列出一层目录。写入、字面量编辑、补丁和删除按目标串行化。移动按字典序锁定两端。`patchText` 在调用 `applyHunks` 前检查版本;`deleteFile` 只删除普通文件。源缺失使用 `FS_STALE_VERSION`,hunk 不匹配使用 `FS_PATCH_MISMATCH`,移动两端相同使用 `FS_SAME_TARGET`。 -补丁与移动的文本在内存中规范化为 LF,写回时还原源文件的行尾风格;结果文本使用 LF。删除基线和被覆盖目标的基线遵循 `diffBasisMaxBytes`。移动在暂存目标前检查取消,随后完成两个效果;源删除失败返回已发布目标及 `sourceDeleted: false`。原子写入失败时,目录或临时目录创建被拒绝映射为 `FS_PERMISSION_DENIED`,其他 Node I/O 错误映射为 `FS_IO_ERROR`。 +补丁与移动的文本在内存中规范化为 LF,写回时还原源文件的行尾风格;结果文本使用 LF。删除基线和被覆盖目标的基线遵循 `diffBasisMaxBytes`。移动在暂存目标前检查取消,随后完成两个效果;源删除失败返回已发布目标及 `sourceDeleted: false`。原子写入失败时,目录或临时目录创建被拒绝映射为 `FS_PERMISSION_DENIED`,其他 Node I/O 错误映射为 `FS_IO_ERROR`。解析、元数据、读取与目录列表失败经由 `dsh-fs` 的 `fsErrorFromErrno` 分类,因此每个 errno 都变为 `FsError` 错误码;不带 errno 码的抛出值属于缺陷,原样传播。 读取、列出与变更操作的失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。编辑遇到缺失目标时,无论是否提供版本防护,都报告 `FS_STALE_VERSION`。 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index a6e3da2bba..63da0a95f0 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -12,7 +12,7 @@ import type { BigIntStats, Dirent, Stats } from 'node:fs' import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path' import { TextDecoder, promisify } from 'node:util' import { homedir } from 'node:os' -import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import { FsError, FsTargetKey, FsVersion, fsErrorFromErrno } from '@deepseek-ai/dsh-fs' import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts' const BINARY_SAMPLE_BYTES = 8192 @@ -62,13 +62,13 @@ function throwIfAborted(signal: AbortSignal | undefined, verb: string): void { * `readFile` with a bare `AbortError`, which would otherwise escape the seam's * error taxonomy — the streaming/write paths translate it the same way). */ -async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise { +async function readFileAbortable(absolutePath: string, displayPath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise { try { return await readFile(absolutePath, signal ? { signal } : {}) } catch (error: unknown) { - /* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */ - if (!isAbortError(error)) throw error - throw new FsError(`${verb} aborted`, 'FS_ABORTED') + /* v8 ignore next 2 -- a non-abort rejection needs a permission fault after a successful stat; POSIX covers it. */ + if (isAbortError(error)) throw new FsError(`${verb} aborted`, 'FS_ABORTED') + throw fsErrorFromErrno(verb, displayPath, error) } } @@ -188,8 +188,7 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise( // ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean // the target is absent; any other metadata failure is a real permission/IO // fault. - /* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */ - if (!isENOENT(error) && !isENOTDIR(error)) throw error + if (!isENOENT(error) && !isENOTDIR(error)) throw fsErrorFromErrno('stat', absolutePath, error) return null } } @@ -291,16 +289,6 @@ export async function probeNoFollow(absolutePath: string): Promise { const identity = await resolveLocalTarget(parent.targetKey, name) return { displayPath: localDisplayPath(parent.displayPath, name), targetKey: identity.targetKey } @@ -320,7 +308,7 @@ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): try { info = await probe(target.targetKey) } catch (error: unknown) { - throw listingIoError(target.displayPath, error) + throw fsErrorFromErrno('list', target.displayPath, error) } if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND') if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY') @@ -330,7 +318,7 @@ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' }) } catch (error: unknown) { /* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */ - throw listingIoError(target.displayPath, error) + throw fsErrorFromErrno('list', target.displayPath, error) } throwIfAborted(signal, 'list') @@ -348,7 +336,7 @@ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): ...(childInfo?.type === 'file' ? { size: childInfo.size } : {}), }) } catch (error: unknown) { - throw listingIoError(localDisplayPath(target.displayPath, entry.name), error) + throw fsErrorFromErrno('list', localDisplayPath(target.displayPath, entry.name), error) } throwIfAborted(signal, 'list') } @@ -392,9 +380,7 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort try { info = await stat(target.targetKey) } catch (error: unknown) { - /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */ - if (!isENOENT(error)) throw error - throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND') + throw fsErrorFromErrno(verb, target.displayPath, error) } if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') return info @@ -409,7 +395,7 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort */ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise { await statRegularFile(target, 'read', signal) - const raw = await readFileAbortable(target.targetKey, 'read', signal) + const raw = await readFileAbortable(target.targetKey, target.displayPath, 'read', signal) throwIfAborted(signal, 'read') if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) { throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') @@ -454,9 +440,9 @@ export async function readWholeBytes( chunks.push(chunk) } } catch (error: unknown) { - /* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ + /* v8 ignore next -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') - throw error + throw fsErrorFromErrno('read', target.displayPath, error) } return Buffer.concat(chunks, bytes) } @@ -491,10 +477,10 @@ export async function readByteWindow( bytes += chunk.length } } catch (error: unknown) { - /* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ + /* v8 ignore next -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') - /* v8 ignore next -- any other stream failure needs an I/O fault after a successful stat. */ - throw error + /* v8 ignore next -- needs a permission fault after a successful stat; Windows chmod cannot deny reads, POSIX covers it. */ + throw fsErrorFromErrno('read', target.displayPath, error) } return Buffer.concat(chunks, bytes) } @@ -529,9 +515,10 @@ export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal } yield decodeUtf8Stream(decoder, undefined, 'read', target.displayPath) } catch (error: unknown) { - /* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */ + /* v8 ignore next -- a mid-stream abort needs cancellation racing an active read; pre-abort is caught by throwIfAborted. */ if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') - throw error + /* v8 ignore next -- needs a permission fault after a successful stat; Windows chmod cannot deny reads, POSIX covers it. */ + throw fsErrorFromErrno('read', target.displayPath, error) } } @@ -776,7 +763,7 @@ export async function readForEdit( signal?: AbortSignal, ): Promise<{ content: string; lineEndings: LineEndings }> { throwIfAborted(signal, 'edit') - const buffer = await readFileAbortable(absolutePath, 'edit', signal) + const buffer = await readFileAbortable(absolutePath, displayPath, 'edit', signal) throwIfAborted(signal, 'edit') if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT') const raw = decodeUtf8(buffer, 'edit', displayPath) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index e53e1e4b37..980f7a722c 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -15,8 +15,10 @@ import { listDirectory, probe, probeNoFollow, + readByteWindow, readForEdit, readTextForDiff, + readWholeBytes, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -1035,3 +1037,42 @@ describe('atomic write failures', () => { }, ) }) + +describe('Node errno classification', () => { + it('classifies metadata, resolution, and read faults on a symlink loop', async () => { + const loop = join(dir, 'loop') + await symlink(loop, loop) + const ioError = { name: 'FsError', code: 'FS_IO_ERROR' } + await expect(probe(loop)).rejects.toMatchObject(ioError) + await expect(resolveLocalTarget(dir, 'loop')).rejects.toMatchObject(ioError) + await expect(readWholeText(localTarget(loop))).rejects.toMatchObject(ioError) + }) + + it('classifies a regular file replaced by a directory after the raw-read stat', async () => { + const file = join(dir, 'swap.bin') + await writeFile(file, 'bytes') + const failure = await readWholeBytes(localTarget(file), undefined, 1024, { + inspectReadBytesAfterStat: async () => { + await unlink(file) + await mkdir(file) + }, + }).then(() => undefined, (caught: unknown) => caught) + expect(failure).toBeInstanceOf(FsError) + expect(['FS_NOT_REGULAR_FILE', 'FS_IO_ERROR']).toContain((failure as FsError).code) + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('maps unreadable file content to FS_PERMISSION_DENIED', async () => { + const file = join(dir, 'locked.txt') + await writeFile(file, 'secret') + await chmod(file, 0o000) + try { + const denied = { name: 'FsError', code: 'FS_PERMISSION_DENIED', message: `cannot read "${file}": permission denied` } + await expect(readWholeText(localTarget(file))).rejects.toMatchObject(denied) + await expect(collect(streamWholeText(localTarget(file)))).rejects.toMatchObject(denied) + await expect(readByteWindow(localTarget(file), { offset: 0, length: 4 })).rejects.toMatchObject(denied) + await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_PERMISSION_DENIED', message: `cannot edit "${file}": permission denied` }) + } finally { + await chmod(file, 0o600) + } + }) +}) diff --git a/packages/fs/fs-sandbox/src/containment.ts b/packages/fs/fs-sandbox/src/containment.ts index 41b9bdd08a..d3706c3b9d 100644 --- a/packages/fs/fs-sandbox/src/containment.ts +++ b/packages/fs/fs-sandbox/src/containment.ts @@ -8,6 +8,7 @@ import type { BigIntStats } from 'node:fs' import { stat } from 'node:fs/promises' import { dirname, sep } from 'node:path' +import { fsErrorFromErrno } from '@deepseek-ai/dsh-fs' const MISSING_CODES: ReadonlySet = new Set(['ENOENT', 'ENOTDIR']) @@ -32,10 +33,8 @@ async function statIfPresent(path: string): Promise { try { return await stat(path, { bigint: true }) } catch (error: unknown) { - /* v8 ignore else -- a non-missing stat failure requires a host permission or I/O fault after resolve reached this ancestor. */ if (isMissing(error)) return undefined - /* v8 ignore next -- requires a host permission or I/O fault after resolve already reached this ancestor. */ - throw error + throw fsErrorFromErrno('stat', path, error) } } @@ -54,6 +53,7 @@ function sameIdentity(left: BigIntStats, right: BigIntStats): boolean { * @param caseSensitive - whether lexical comparison preserves case; defaults * to the host filesystem convention used by supported platforms. * @returns whether the target is the root or a descendant of it. + * @throws FsError when an ancestor or the root cannot be inspected, coded by the Node errno. */ export async function isPathUnder( path: string, diff --git a/packages/fs/fs-sandbox/tests/containment.spec.ts b/packages/fs/fs-sandbox/tests/containment.spec.ts index 35dc52029b..c77fbcf4d2 100644 --- a/packages/fs/fs-sandbox/tests/containment.spec.ts +++ b/packages/fs/fs-sandbox/tests/containment.spec.ts @@ -54,4 +54,12 @@ describe('filesystem sandbox containment', () => { await writeFile(blocker, 'not a directory') expect(await isPathUnder(join(blocker, 'child.txt'), allowed)).toBe(false) }) + + it('classifies an ancestor inspection fault as a filesystem error', async () => { + const root = join(base, 'root') + const loop = join(base, 'loop') + await mkdir(root) + await symlink(loop, loop) + await expect(isPathUnder(loop, root)).rejects.toMatchObject({ name: 'FsError', code: 'FS_IO_ERROR' }) + }) }) diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 7e3bfe3472..8f15755d9e 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8e2f904a21ba4dfb zh: 76d9ce7503c84a5c /deepseek-ai-dsh-fs/use-this-package/what-the-service-lets-you-do: - en: 637fc69ab6f6d19f - zh: 0fceafe5b38a06a1 + en: 5781e5a173192374 + zh: a3fa379ea951921d /deepseek-ai-dsh-fs/understand-the-implementation: en: 7e13d669ecc96d59 zh: b5fe7ae65812d926 @@ -30,8 +30,8 @@ en: 955232f787df6d64 zh: ab572b040ab3554e /deepseek-ai-dsh-fs/understand-the-implementation/source-map: - en: 9507d7d9259fc89e - zh: aa500435e02e6de3 + en: 8ab63fdfa9f95759 + zh: bb88918900d7458b /deepseek-ai-dsh-fs/understand-the-implementation/how-a-call-flows: en: 3cefede64907731c zh: 529ef3851c2fb5c1 diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 949a453d2c..e2fe18a41c 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -33,7 +33,7 @@ Pick [`fs-local`](../fs-local/README.md) for ordinary host files or [`fs-sandbox ### What the service lets you do -Through `ctx.fs`, resolve paths, read text or bounded bytes, list direct children, and perform guarded mutations. `patchText` applies parsed hunks under the same lock as its version check. `deleteFile` removes a regular file. `moveFile` locks both targets, optionally patches the source text, publishes the destination, then removes the source. Guards are optional: omission mutates the current state unconditionally. Failures carry stable `FsError` codes from the runtime `FS_ERROR_CODES` list, which transport validators reuse. +Through `ctx.fs`, resolve paths, read text or bounded bytes, list direct children, and perform guarded mutations. `patchText` applies parsed hunks under the same lock as its version check. `deleteFile` removes a regular file. `moveFile` locks both targets, optionally patches the source text, publishes the destination, then removes the source. Guards are optional: omission mutates the current state unconditionally. Failures carry stable `FsError` codes from the runtime `FS_ERROR_CODES` list, which transport validators reuse. Host-backed providers, currently `dsh-fs-local` and the `dsh-fs-sandbox` containment check, classify errno failures with `fsErrorFromErrno`: `ENOENT` and `ENOTDIR` become `FS_NOT_FOUND`, `EACCES` and `EPERM` become `FS_PERMISSION_DENIED`, `EISDIR` where a file is required becomes `FS_NOT_REGULAR_FILE`, and other errno values become `FS_IO_ERROR`, with the original error as `cause`. `watch(target, changed, signal)` reports invalidations for one file or a directory's direct entries. It resolves once observation is ready with an asynchronous close function that the caller must await. The signal cancels initialization; unsupported providers reject without polling. @@ -62,6 +62,7 @@ The contract is built on one separation and three commitments: |---|---| | [`src/index.ts`](src/index.ts) | Service definition: the abstract `FileSystem` class, the `ctx.fs` declaration, and the `fs/*` event vocabulary | | [`src/types.ts`](src/types.ts) | Vocabulary: `FsTarget`/`FsTargetKey`, `FsVersion`, `FsObservation`, `FsWriteIntent`, `FsError` and its codes | +| [`src/errno.ts`](src/errno.ts) | `fsErrorFromErrno`: errno classification into `FsError` codes | ### How a call flows diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 90118d2ecf..5056198f1b 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 服务能做什么 -通过 `ctx.fs` 可解析路径、读取文本或有界字节、列出直接子项并执行受防护变更。`patchText` 在版本检查的同一把锁内应用解析后的 hunk。`deleteFile` 删除普通文件。`moveFile` 锁定两端,按需修改源文本,发布目标后再删除源。防护是可选的:省略时无条件修改当前状态。失败携带运行时列表 `FS_ERROR_CODES` 中的稳定 `FsError` 错误码,传输校验器复用该列表。 +通过 `ctx.fs` 可解析路径、读取文本或有界字节、列出直接子项并执行受防护变更。`patchText` 在版本检查的同一把锁内应用解析后的 hunk。`deleteFile` 删除普通文件。`moveFile` 锁定两端,按需修改源文本,发布目标后再删除源。防护是可选的:省略时无条件修改当前状态。失败携带运行时列表 `FS_ERROR_CODES` 中的稳定 `FsError` 错误码,传输校验器复用该列表。基于宿主文件系统的提供方(目前是 `dsh-fs-local` 与 `dsh-fs-sandbox` 的包含性检查)用 `fsErrorFromErrno` 分类 errno 失败:`ENOENT` 与 `ENOTDIR` 变为 `FS_NOT_FOUND`,`EACCES` 与 `EPERM` 变为 `FS_PERMISSION_DENIED`,需要文件却遇到目录的 `EISDIR` 变为 `FS_NOT_REGULAR_FILE`,其他 errno 变为 `FS_IO_ERROR`,原错误作为 `cause`。 `watch(target, changed, signal)` 报告单个文件或目录直接子项的失效通知。观察就绪后,它返回调用方必须等待完成的异步关闭函数。signal 取消初始化;不支持监听的提供方直接拒绝,不使用轮询。 @@ -62,6 +62,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 服务定义:抽象 `FileSystem` 类、`ctx.fs` 声明与 `fs/*` 事件词汇 | | [`src/types.ts`](src/types.ts) | 词汇:`FsTarget`/`FsTargetKey`、`FsVersion`、`FsObservation`、`FsWriteIntent`、`FsError` 及其错误码 | +| [`src/errno.ts`](src/errno.ts) | `fsErrorFromErrno`:把 errno 分类为 `FsError` 错误码 | ### 调用流程 diff --git a/packages/fs/fs/src/errno.ts b/packages/fs/fs/src/errno.ts new file mode 100644 index 0000000000..5378ef58bd --- /dev/null +++ b/packages/fs/fs/src/errno.ts @@ -0,0 +1,33 @@ +/** + * Classification of host errno failures into the shared filesystem codes. + * @module @deepseek-ai/dsh-fs/errno + */ + +import { FsError } from './types.ts' + +/** + * Classify a host filesystem rejection by its errno `code`: ENOENT and ENOTDIR become `FS_NOT_FOUND`, + * EACCES and EPERM `FS_PERMISSION_DENIED`, EISDIR (a directory where a file is required) + * `FS_NOT_REGULAR_FILE`, and any other errno `FS_IO_ERROR`, each with the original error as `cause`. + * Callers that give an errno a narrower meaning, such as absence during a probe, test for it first. + * @param operation - verb shown in the diagnostic, such as `read`, `stat`, or `list`. + * @param displayPath - caller-facing path shown in the diagnostic. + * @param error - the caught value. + * @returns the classified error; an `FsError` and a value without a string errno `code` (a defect) are returned unchanged. + */ +export function fsErrorFromErrno(operation: string, displayPath: string, error: unknown): unknown { + if (error instanceof FsError || !(error instanceof Error) || !('code' in error) || typeof error.code !== 'string') return error + const failure = `cannot ${operation} "${displayPath}"` + switch (error.code) { + case 'ENOENT': + case 'ENOTDIR': + return new FsError(`${failure}: not found`, 'FS_NOT_FOUND', { cause: error }) + case 'EACCES': + case 'EPERM': + return new FsError(`${failure}: permission denied`, 'FS_PERMISSION_DENIED', { cause: error }) + case 'EISDIR': + return new FsError(`${failure}: not a regular file`, 'FS_NOT_REGULAR_FILE', { cause: error }) + default: + return new FsError(`${failure}: ${error.message}`, 'FS_IO_ERROR', { cause: error }) + } +} diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index a49e7ff0e2..8baff464f9 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -27,6 +27,7 @@ import type { FsWriteOutcome, } from './types.ts' +export { fsErrorFromErrno } from './errno.ts' export { FS_ERROR_CODES, FsError, diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 6ece2c04e4..ecc8b182e5 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -203,6 +203,7 @@ export const FS_ERROR_CODES = [ 'FS_ABORTED', 'FS_PATCH_MISMATCH', 'FS_SAME_TARGET', + 'FS_ALREADY_EXISTS', ] as const /** One machine-routable filesystem failure code from FS_ERROR_CODES. */ @@ -219,6 +220,7 @@ export class FsError extends HarnessError { constructor(message: string, code: FsErrorCode, options?: ErrorOptions) { super(message, code, options) + this.name = 'FsError' this.code = code } } diff --git a/packages/fs/fs/tests/errno.spec.ts b/packages/fs/fs/tests/errno.spec.ts new file mode 100644 index 0000000000..be52ee66ed --- /dev/null +++ b/packages/fs/fs/tests/errno.spec.ts @@ -0,0 +1,30 @@ +/** Errno classification into the shared filesystem codes. */ +import { describe, expect, it } from 'vitest' +import { FsError, fsErrorFromErrno } from '@deepseek-ai/dsh-fs' + +describe('fsErrorFromErrno', () => { + const errno = (code: string): Error => Object.assign(new Error(`${code}: injected`), { code }) + + it.each([ + ['ENOENT', 'FS_NOT_FOUND', 'not found'], + ['ENOTDIR', 'FS_NOT_FOUND', 'not found'], + ['EACCES', 'FS_PERMISSION_DENIED', 'permission denied'], + ['EPERM', 'FS_PERMISSION_DENIED', 'permission denied'], + ['EISDIR', 'FS_NOT_REGULAR_FILE', 'not a regular file'], + ['ELOOP', 'FS_IO_ERROR', 'ELOOP: injected'], + ])('maps %s to %s with the Node error as cause', (code, fsCode, detail) => { + const cause = errno(code) + expect(fsErrorFromErrno('read', '~/a', cause)).toMatchObject({ + name: 'FsError', code: fsCode, message: `cannot read "~/a": ${detail}`, cause, + }) + }) + + it.each([ + new FsError('already typed', 'FS_TOO_LARGE'), + new Error('defect'), + Object.assign(new Error('numeric code'), { code: 1 }), + 'not an error', + ])('returns typed failures and defects unchanged: %s', (value) => { + expect(fsErrorFromErrno('read', 'a', value)).toBe(value) + }) +}) From 36d45eca9cd87f4ef05f9527c02e2bb5b9283e16 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:43 +0800 Subject: [PATCH 02/81] feat(str-replace-editor): give argument and create failures error codes Hand-checked argument failures (relative or empty path, missing command parameters, invalid view_range, out-of-range insert_line, null new_str) throw ToolArgsError (INVALID_ARGS), and create on an existing path throws FsError FS_ALREADY_EXISTS, so tool results persist error.info. --- .../tool-str-replace-editor/README.i18n.yaml | 4 +- packages/fs/tool-str-replace-editor/README.md | 2 +- .../fs/tool-str-replace-editor/README.zh.md | 2 +- .../fs/tool-str-replace-editor/src/index.ts | 36 +++++------ .../tests/tools.spec.ts | 60 ++++++++++--------- 5 files changed, 54 insertions(+), 50 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 7bd12ad505..b1cc3a78da 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -27,8 +27,8 @@ en: 554e8446946fbd96 zh: 3af12627c19a0111 /deepseek-ai-dsh-tool-str-replace-editor/use-this-package/failures-and-recovery: - en: 2b249fcb0b5621aa - zh: d172b3de43c337c4 + en: 68d14f5993c734d5 + zh: 36eab11474947735 /deepseek-ai-dsh-tool-str-replace-editor/understand-the-implementation: en: b0b36a22cd4421eb zh: db47ef5b71dd51df diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index f1880a5217..563ddf6ce1 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -52,7 +52,7 @@ A backend, optionally the policy plugin, then the tool; the editor composes with ### Failures and recovery -A metadata miss from `view`, `str_replace`, or `insert` records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Guarded mutations inherit the policy plugin's codes and remedies — `FS_NOT_OBSERVED` (read the file, then retry), `FS_STALE_VERSION` (re-read, then retry) — and sandbox denials surface as the `[sandbox: file access denied under mode]` marker. Paths must be absolute; a relative path is refused with a hint. +A metadata miss from `view`, `str_replace`, or `insert` records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Guarded mutations inherit the policy plugin's codes and remedies — `FS_NOT_OBSERVED` (read the file, then retry), `FS_STALE_VERSION` (re-read, then retry) — and sandbox denials surface as the `[sandbox: file access denied under mode]` marker. Paths must be absolute; a relative path is refused with a hint. Argument mistakes the schema cannot express, such as a relative path, a missing command parameter, or an out-of-range `view_range` or `insert_line`, throw `ToolArgsError` with `INVALID_ARGS`; `create` on an existing path reports `FS_ALREADY_EXISTS`. ----- diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index 9c69505356..5182960718 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -52,7 +52,7 @@ kind: "package-reference" ### 失败与恢复 -`view`、`str_replace` 或 `insert` 发生元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。防护变更继承策略插件的错误码与恢复指令——`FS_NOT_OBSERVED`(先读取文件再重试)、`FS_STALE_VERSION`(先重新读取再重试)——沙箱拒绝则表现为 `[sandbox: file access denied under mode]` 标记。路径必须是绝对路径;相对路径会被拒绝并给出提示。 +`view`、`str_replace` 或 `insert` 发生元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。防护变更继承策略插件的错误码与恢复指令——`FS_NOT_OBSERVED`(先读取文件再重试)、`FS_STALE_VERSION`(先重新读取再重试)——沙箱拒绝则表现为 `[sandbox: file access denied under mode]` 标记。路径必须是绝对路径;相对路径会被拒绝并给出提示。参数模式无法表达的参数错误,例如相对路径、缺少命令参数或 `view_range`、`insert_line` 越界,抛出 `ToolArgsError`,错误码为 `INVALID_ARGS`;对已存在路径执行 `create` 报告 `FS_ALREADY_EXISTS`。 ----- diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 3542ee1770..099d1a6d73 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -12,7 +12,7 @@ import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-r import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolRunContext } 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.' @@ -92,9 +92,9 @@ async function resolveTarget( path: string, signal: AbortSignal, ): Promise { - if (path.trim().length === 0) throw new Error('path must be a non-empty string') + if (path.trim().length === 0) throw new ToolArgsError(['path must be a non-empty string']) if (!isAbsolute(path)) { - throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) + throw new ToolArgsError([`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`]) } return ctx.fs.resolve(path, { signal }) } @@ -128,9 +128,9 @@ function requiredForCommand( command: string, allowEmpty = true, ): string { - if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`) + if (value === undefined) throw new ToolArgsError([`Parameter \`${parameter}\` is required for command: ${command}`]) if (!allowEmpty && value.length === 0) { - throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`) + throw new ToolArgsError([`Parameter \`${parameter}\` is empty for command: ${command}`]) } return value } @@ -154,24 +154,24 @@ function formatFileView( || requestedFinalLine === undefined || !viewRange.every(Number.isInteger) ) { - throw new Error('Invalid `view_range`. It should be a list of two integers.') + throw new ToolArgsError(['Invalid `view_range`. It should be a list of two integers.']) } initialLine = requestedInitialLine finalLine = requestedFinalLine if (initialLine < 1 || initialLine > allLines.length) { - throw new Error( + throw new ToolArgsError([ `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`, - ) + ]) } if (finalLine > allLines.length) { - throw new Error( + throw new ToolArgsError([ `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``, - ) + ]) } if (finalLine !== -1 && finalLine < initialLine) { - throw new Error( + throw new ToolArgsError([ `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``, - ) + ]) } lines = finalLine === -1 ? allLines.slice(initialLine - 1) @@ -226,7 +226,7 @@ async function viewPath( const info = await statExisting(ctx, target, 'view', exec) if (info.type === 'directory') { if (viewRange !== undefined) { - throw new Error('The `view_range` parameter is not allowed when `path` points to a directory.') + throw new ToolArgsError(['The `view_range` parameter is not allowed when `path` points to a directory.']) } return listDirectory(ctx, target, maxOutputChars, exec) } @@ -249,7 +249,7 @@ async function createFile( const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, exec.signal) if (await ctx.fs.stat(target, exec.signal) !== undefined) { - throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) + throw new FsError(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`, 'FS_ALREADY_EXISTS') } const intent = await ctx.waterfall( 'fs/write-intent', @@ -282,7 +282,7 @@ async function replaceInFile( exec: ToolRunContext, ): Promise { if (newStr === null) { - throw new Error('Parameter `new_str` must be omitted or contain a string for command: str_replace') + throw new ToolArgsError(['Parameter `new_str` must be omitted or contain a string for command: str_replace']) } const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, exec.signal) @@ -335,7 +335,7 @@ async function insertInFile( newStr: string | undefined, exec: ToolRunContext, ): Promise { - if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') + if (insertLine === undefined) throw new ToolArgsError(['Parameter `insert_line` is required for command: insert']) const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, exec.signal) @@ -347,9 +347,9 @@ async function insertInFile( const before = await ctx.fs.readText(target, exec.signal) const lines = before.split('\n') if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { - throw new Error( + throw new ToolArgsError([ `Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`, - ) + ]) } const after = [ ...lines.slice(0, insertLine), diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 14de14c4cd..e08c539c59 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -460,6 +460,7 @@ describe('tool-str-replace-editor', () => { const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) expect(relative.isError).toBe(true) expect(text(relative)).toContain('is not an absolute path') + expect(relative.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta') }) @@ -476,35 +477,38 @@ describe('tool-str-replace-editor', () => { await writeFile(threeLines, 'one\ntwo\nthree') await mkdir(directory) - const cases = [ - { command: null, path: ambiguous }, - { command: 'view', path: null }, - { command: 'view', path: '' }, - { command: 'view', path: join(root, 'missing.txt') }, - { command: 'view', path: ambiguous, view_range: [1] }, - { command: 'view', path: ambiguous, view_range: [0, 1] }, - { command: 'view', path: ambiguous, view_range: [1.5, 2] }, - { command: 'view', path: threeLines, view_range: [1, 99] }, - { command: 'view', path: threeLines, view_range: [2, 1] }, - { command: 'view', path: directory, view_range: [1, 1] }, - { command: 'create', path: join(root, 'new.txt') }, - { command: 'create', path: join(root, 'new.txt'), file_text: null }, - { command: 'create', path: ambiguous, file_text: 'overwrite' }, - { command: 'str_replace', path: ambiguous, new_str: 'x' }, - { command: 'str_replace', path: ambiguous, old_str: null, new_str: 'x' }, - { command: 'str_replace', path: ambiguous, old_str: 'same same', new_str: null }, - { command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' }, - { command: 'insert', path: ambiguous, new_str: 'x' }, - { command: 'insert', path: ambiguous, insert_line: null, new_str: 'x' }, - { command: 'insert', path: ambiguous, insert_line: 0, new_str: null }, - { command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' }, - { command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' }, - { command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' }, - { command: 'insert', path: empty, insert_line: 2, new_str: 'x' }, - { command: 'insert', path: directory, insert_line: 0, new_str: 'x' }, + const args = { name: 'ToolArgsError', code: 'INVALID_ARGS' } + const cases: [Record, { name: string; code: string }][] = [ + [{ command: null, path: ambiguous }, args], + [{ command: 'view', path: null }, args], + [{ command: 'view', path: '' }, args], + [{ command: 'view', path: join(root, 'missing.txt') }, { name: 'FsError', code: 'FS_NOT_FOUND' }], + [{ command: 'view', path: ambiguous, view_range: [1] }, args], + [{ command: 'view', path: ambiguous, view_range: [0, 1] }, args], + [{ command: 'view', path: ambiguous, view_range: [1.5, 2] }, args], + [{ command: 'view', path: threeLines, view_range: [1, 99] }, args], + [{ command: 'view', path: threeLines, view_range: [2, 1] }, args], + [{ command: 'view', path: directory, view_range: [1, 1] }, args], + [{ command: 'create', path: join(root, 'new.txt') }, args], + [{ command: 'create', path: join(root, 'new.txt'), file_text: null }, args], + [{ command: 'create', path: ambiguous, file_text: 'overwrite' }, { name: 'FsError', code: 'FS_ALREADY_EXISTS' }], + [{ command: 'str_replace', path: ambiguous, new_str: 'x' }, args], + [{ command: 'str_replace', path: ambiguous, old_str: null, new_str: 'x' }, args], + [{ command: 'str_replace', path: ambiguous, old_str: 'same same', new_str: null }, args], + [{ command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, insert_line: null, new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, insert_line: 0, new_str: null }, args], + [{ command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' }, args], + [{ command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' }, args], + [{ command: 'insert', path: empty, insert_line: 2, new_str: 'x' }, args], + [{ command: 'insert', path: directory, insert_line: 0, new_str: 'x' }, { name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }], ] - for (const args of cases) { - expect((await call(ctx, owner, args)).isError).toBe(true) + for (const [callArgs, info] of cases) { + const result = await call(ctx, owner, callArgs) + expect(result.isError).toBe(true) + expect({ args: callArgs, info: result.error?.info }).toEqual({ args: callArgs, info }) } expect(await readFile(ambiguous, 'utf8')).toBe('same same') From 7d6b1197ddf6d938c2583b0a6a037305ade2b335 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:43 +0800 Subject: [PATCH 03/81] feat(fs-search): report glob and grep argument failures as ToolArgsError Blank pattern or path and invalid include filters throw ToolArgsError (INVALID_ARGS) instead of a plain Error. --- packages/fs/tool-fs-search/README.i18n.yaml | 4 +-- packages/fs/tool-fs-search/README.md | 2 +- packages/fs/tool-fs-search/README.zh.md | 2 +- packages/fs/tool-fs-search/src/glob.ts | 10 +++---- packages/fs/tool-fs-search/src/grep.ts | 14 +++++----- .../fs/tool-fs-search/tests/tools.spec.ts | 27 ++++++++++++++----- 6 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index b1c75185bb..df28d1dc0a 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -30,8 +30,8 @@ en: 4aa9b74deef586bc zh: f855f815829d4e1e /deepseek-ai-dsh-tool-fs-search/use-this-package/failures-and-recovery: - en: d7a59e296308b2bd - zh: 66243fcd932fe49a + en: ce6065aa8f2a04b6 + zh: bc862345e48e52d4 /deepseek-ai-dsh-tool-fs-search/understand-the-implementation: en: 7546db09c46181e0 zh: 3aae6dc6a773511c diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 4c26ffa018..008741bb5d 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -76,7 +76,7 @@ Node deployments receive the `@vscode/ripgrep` platform package on supported mac ### Failures and recovery -Search failures carry the package-owned codes `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex or glob), `SEARCH_FAILED` (a failed launch, inaccessible target, signal kill, or malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over the cap), and `SEARCH_ABORTED` (cooperative timeout or caller cancellation). Exit 0 is success with results and exit 1 is a successful empty search; model argument mistakes stay ordinary tool argument errors. +Search failures carry the package-owned codes `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex or glob), `SEARCH_FAILED` (a failed launch, inaccessible target, signal kill, or malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over the cap), and `SEARCH_ABORTED` (cooperative timeout or caller cancellation). Exit 0 is success with results and exit 1 is a successful empty search; model argument mistakes throw `ToolArgsError` with `INVALID_ARGS`. ----- diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 10cae42308..2f38854de2 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -76,7 +76,7 @@ Node 部署在受支持的 macOS、Linux 与 Windows 目标上获得 `@vscode/ri ### 失败与恢复 -搜索失败携带本包定义的错误码:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则或 glob)、`SEARCH_FAILED`(启动失败、目标不可访问、信号终止或 `--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过上限)与 `SEARCH_ABORTED`(协作式超时或调用方取消)。退出 0 表示成功且有结果,退出 1 表示成功的空搜索;模型参数错误仍是普通工具参数错误。 +搜索失败携带本包定义的错误码:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则或 glob)、`SEARCH_FAILED`(启动失败、目标不可访问、信号终止或 `--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过上限)与 `SEARCH_ABORTED`(协作式超时或调用方取消)。退出 0 表示成功且有结果,退出 1 表示成功的空搜索;模型参数错误抛出 `ToolArgsError`,错误码为 `INVALID_ARGS`。 ----- diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 31df85aa53..99af2c5b75 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -11,7 +11,7 @@ import type { Context } from '@deepseek-ai/cordis' import { sep } from 'node:path' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { SpillRef } from '@deepseek-ai/dsh-spill' import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' @@ -62,15 +62,15 @@ export interface GlobInput { /** * Validate value constraints the schema DSL can't express: a non-blank - * `pattern`, and a non-blank `path` when given. Throws a plain `Error` (an - * ordinary tool argument error) otherwise. + * `pattern`, and a non-blank `path` when given. Throws `ToolArgsError` + * (`INVALID_ARGS`) otherwise. * * @param args - the schema-validated `glob` arguments. * @returns the accepted input, unchanged. */ export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInput { - if (args.pattern.trim().length === 0) throw new Error('pattern must be a non-empty string') - if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given') + if (args.pattern.trim().length === 0) throw new ToolArgsError(['pattern must be a non-empty string']) + if (args.path !== undefined && args.path.trim().length === 0) throw new ToolArgsError(['path must be a non-empty string when given']) return { pattern: args.pattern, ...args.path !== undefined ? { path: args.path } : {} } } diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index e0246d6b76..1a6fd00bd8 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -12,7 +12,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { RetainedItems } from '@deepseek-ai/dsh-output-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' @@ -65,14 +65,14 @@ export interface GrepInput { * group is fine — `*.{ts,tsx}` is one glob with alternation, not a list. */ function validateInclude(include: string): void { - if (include.trim().length === 0) throw new Error('include must be a non-empty glob when given') - if (include.startsWith('!')) throw new Error('include must be a positive glob filter; negated patterns ("!…") are not supported') + if (include.trim().length === 0) throw new ToolArgsError(['include must be a non-empty glob when given']) + if (include.startsWith('!')) throw new ToolArgsError(['include must be a positive glob filter; negated patterns ("!…") are not supported']) let braceDepth = 0 for (const char of include) { if (char === '{') braceDepth++ else if (char === '}') braceDepth = Math.max(0, braceDepth - 1) else if (char === ',' && braceDepth === 0) { - throw new Error('include must be one glob, not a comma-separated list (use {a,b} alternation instead)') + throw new ToolArgsError(['include must be one glob, not a comma-separated list (use {a,b} alternation instead)']) } } } @@ -82,14 +82,14 @@ function validateInclude(include: string): void { * `pattern` (whitespace is a legitimate regex), a non-blank `path` when given, * and a single positive `include` glob ({@link GrepInput}). An exact empty * `include` is treated as omitted; whitespace-only values remain invalid. - * Throws a plain `Error` (an ordinary tool argument error) otherwise. + * Throws `ToolArgsError` (`INVALID_ARGS`) otherwise. * * @param args - the schema-validated `grep` arguments. * @returns the accepted input with an empty `include` omitted. */ export function parseGrepArgs(args: { pattern: string; path?: string; include?: string }): GrepInput { - if (args.pattern.length === 0) throw new Error('pattern must be a non-empty string') - if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given') + if (args.pattern.length === 0) throw new ToolArgsError(['pattern must be a non-empty string']) + if (args.path !== undefined && args.path.trim().length === 0) throw new ToolArgsError(['path must be a non-empty string when given']) const include = args.include === '' ? undefined : args.include if (include !== undefined) validateInclude(include) return { diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 5763aacc15..529d93805a 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -754,8 +754,14 @@ describe('glob results', () => { it('validates arguments (blank pattern, blank path)', async () => { const { ctx } = await setup() - expect(text(await call(ctx, 'glob', { pattern: ' ' }))).toContain('pattern must be a non-empty string') - expect(text(await call(ctx, 'glob', { pattern: '*', path: ' ' }))).toContain('path must be a non-empty string') + for (const [args, message] of [ + [{ pattern: ' ' }, 'pattern must be a non-empty string'], + [{ pattern: '*', path: ' ' }, 'path must be a non-empty string'], + ] as const) { + const result = await call(ctx, 'glob', args) + expect(text(result)).toContain(message) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + } }) it('threads a valid path through to the spawn as the plain search root element', async () => { @@ -1100,16 +1106,23 @@ describe('grep results', () => { const result = await call(ctx, 'grep', args) expect(result.isError).toBe(true) expect(text(result)).toContain(message) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) expect(subprocess.spawns).toHaveLength(0) }) it('validates arguments (empty pattern, blank path, bad include)', async () => { const { ctx } = await setup() - expect(text(await call(ctx, 'grep', { pattern: '' }))).toContain('pattern must be a non-empty string') - expect(text(await call(ctx, 'grep', { pattern: 'x', path: ' ' }))).toContain('path must be a non-empty string') - expect(text(await call(ctx, 'grep', { pattern: 'x', include: ' ' }))).toContain('include must be a non-empty glob') - expect(text(await call(ctx, 'grep', { pattern: 'x', include: '!*.ts' }))).toContain('negated patterns') - expect(text(await call(ctx, 'grep', { pattern: 'x', include: '*.ts,*.js' }))).toContain('comma-separated list') + for (const [args, message] of [ + [{ pattern: '' }, 'pattern must be a non-empty string'], + [{ pattern: 'x', path: ' ' }, 'path must be a non-empty string'], + [{ pattern: 'x', include: ' ' }, 'include must be a non-empty glob'], + [{ pattern: 'x', include: '!*.ts' }, 'negated patterns'], + [{ pattern: 'x', include: '*.ts,*.js' }, 'comma-separated list'], + ] as const) { + const result = await call(ctx, 'grep', args) + expect(text(result)).toContain(message) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + } }) it('accepts a whitespace-only pattern (a legitimate regex) and brace alternation in include', async () => { From a0766d7573dff6789e3a8b8d303b64800215b22a Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:44 +0800 Subject: [PATCH 04/81] feat(tool-fs): report read, write, and edit argument failures as ToolArgsError read offset/limit/file_path, write file_path, edit file_path/old_string checks, and an unsupported sandbox_permissions request throw ToolArgsError (INVALID_ARGS). Integration tests pin that host metadata and permission faults now reach read results as FsError codes. --- packages/fs/tool-fs/src/edit.ts | 8 +++---- packages/fs/tool-fs/src/read.ts | 8 +++---- packages/fs/tool-fs/src/sandbox.ts | 3 ++- packages/fs/tool-fs/src/write.ts | 4 ++-- packages/fs/tool-fs/tests/integration.spec.ts | 23 ++++++++++++++++++- packages/fs/tool-fs/tests/tools.spec.ts | 10 ++++++++ 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index f927ccb1cb..8de03306ca 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -6,7 +6,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' @@ -45,9 +45,9 @@ interface EditToolArgs { * @returns the camelCased input with `replace_all` defaulted to false. */ export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput { - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') - if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string') - if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) + if (args.old_string.length === 0) throw new ToolArgsError(['old_string must be a non-empty string']) + if (args.old_string === args.new_string) throw new ToolArgsError(['old_string and new_string must differ']) return { filePath: args.file_path, oldString: args.old_string, diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 22c2dc52c9..fcd42c3e07 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -5,7 +5,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts' @@ -41,7 +41,7 @@ interface ReadInput { function parsePositiveInteger(value: number, name: string): number { if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) { - throw new Error(`${name} must be a positive integer`) + throw new ToolArgsError([`${name} must be a positive integer`]) } return value } @@ -53,10 +53,10 @@ function parsePositiveInteger(value: number, name: string): number { * @returns the validated input with `offset` defaulted to 1 and `limit` to `maxLimit`. */ export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput { - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset') const limit = args.limit === undefined ? maxLimit : parsePositiveInteger(args.limit, 'limit') - if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`) + if (limit > maxLimit) throw new ToolArgsError([`limit must be less than or equal to ${maxLimit}`]) return { filePath: args.file_path, offset, limit } } diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index c962d6a147..08b776fd92 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -11,6 +11,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { ToolArgsError } from '@deepseek-ai/dsh-tools' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' @@ -96,7 +97,7 @@ export class FsSandboxController { return standingPolicy } if (this.escalationModes.length === 0) { - throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)') + throw new ToolArgsError(['sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)']) } const policy = standingPolicy as SandboxExecutionPolicy const approvedMode = await approveEscalation( diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 0745ffc98d..37d1263d20 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -6,7 +6,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' @@ -23,7 +23,7 @@ import type { FsSandboxController } from './sandbox.ts' * @returns the camelCased input; `content` passes through untouched. */ export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) return { filePath: args.file_path, content: args.content } } diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 25d428c8e6..32d42fd4bb 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -10,7 +10,7 @@ import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mountAgentLoopTestDependencies, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -355,6 +355,27 @@ describe('bare provider (no dsh-fs-observation-policy)', () => { expect(text(result)).toContain('1: alpha') }) + it('reports a host metadata fault as a typed filesystem failure', async () => { + await symlink(join(dir, 'loop'), join(dir, 'loop')) + const result = await call('read', { file_path: 'loop' }) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_IO_ERROR' }) + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('reports unreadable content as FS_PERMISSION_DENIED', async () => { + const file = join(dir, 'locked.txt') + await writeFile(file, 'secret') + await chmod(file, 0o000) + try { + const result = await call('read', { file_path: 'locked.txt' }) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_PERMISSION_DENIED' }) + expect(text(result)).toContain('permission denied') + } finally { + await chmod(file, 0o600) + } + }) + it('write unconditionally creates a new file', async () => { const result = await call('write', { file_path: 'new.txt', content: 'fresh' }) expect(result.isError).toBe(false) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 8ba5a105cd..f1082845f2 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -287,6 +287,7 @@ describe('read tool', () => { const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 }) expect(result.isError).toBe(true) expect(text(result)).toContain('offset must be a positive integer') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('rejects a fractional offset and a zero/negative limit', async () => { @@ -299,6 +300,7 @@ describe('read tool', () => { const result = await call(ctx, 'read', args) expect(result.isError, JSON.stringify(args)).toBe(true) expect(text(result)).toMatch(/must be a positive integer/) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) } }) @@ -314,6 +316,7 @@ describe('read tool', () => { const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 }) expect(result.isError).toBe(true) expect(text(result)).toContain('less than or equal to 2000') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('rejects a blank file_path', async () => { @@ -321,6 +324,7 @@ describe('read tool', () => { const result = await call(ctx, 'read', { file_path: ' ' }) expect(result.isError).toBe(true) expect(text(result)).toContain('file_path must be a non-empty string') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('records observed state so a follow-up edit by the same session is authorized', async () => { @@ -456,6 +460,7 @@ describe('write tool', () => { const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' }) expect(result.isError).toBe(true) expect(text(result)).toContain('file_path must be a non-empty string') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('propagates a backend FsError as an isError result carrying its code and remedy', async () => { @@ -494,6 +499,7 @@ describe('edit tool', () => { const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' }) expect(result.isError).toBe(true) expect(text(result)).toContain('must differ') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('rejects an empty old_string', async () => { @@ -501,6 +507,7 @@ describe('edit tool', () => { const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' }) expect(result.isError).toBe(true) expect(text(result)).toContain('old_string must be a non-empty string') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('rejects a blank file_path', async () => { @@ -508,6 +515,7 @@ describe('edit tool', () => { const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) expect(text(result)).toContain('file_path must be a non-empty string') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => { @@ -788,6 +796,7 @@ describe('read caps are plugin config', () => { const overCap = await call(ctx, 'read', { file_path: 'a.txt', limit: 3 }) expect(overCap.isError).toBe(true) expect(text(overCap)).toContain('less than or equal to 2') + expect(overCap.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) const readSchema = ctx.tools.schemas().find(s => s.name === 'read') expect(JSON.stringify(readSchema)).toContain('Defaults to 2.') }) @@ -1141,6 +1150,7 @@ describe('sandbox escalation API (write/edit)', () => { const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write', justification: 'why' }, escalationAgent()) expect(result.isError).toBe(true) expect(text(result)).toContain('not available in this composition') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) }) From b605b3dab2d4e5138a96d65e27e1537af3fe1f5a Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:28:45 +0800 Subject: [PATCH 05/81] feat(present): give present failures structured error codes A missing agent Session or open turn throws ToolContextUnavailableError, an empty path or out-of-range file count throws ToolArgsError, and a directory or final symlink throws FsError FS_NOT_REGULAR_FILE. --- .../tool-present/README.i18n.yaml | 4 +-- packages/deliverables/tool-present/README.md | 2 +- .../deliverables/tool-present/README.zh.md | 2 +- .../deliverables/tool-present/src/index.ts | 14 +++++----- .../tool-present/tests/present.spec.ts | 26 ++++++++++++++++--- 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/deliverables/tool-present/README.i18n.yaml b/packages/deliverables/tool-present/README.i18n.yaml index 7de5647086..d5b2ce4e60 100644 --- a/packages/deliverables/tool-present/README.i18n.yaml +++ b/packages/deliverables/tool-present/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-tool-present/use-this-package: - en: d1a1e22686f9041d - zh: 8053a913199195fa + en: aecb160dd6ee85ba + zh: 148288a2e92236fa /deepseek-ai-dsh-tool-present/understand-the-implementation: en: 6e73ca41dddc096e zh: b59fdfa3f3cc47e1 diff --git a/packages/deliverables/tool-present/README.md b/packages/deliverables/tool-present/README.md index 7439bda570..031cf546d4 100644 --- a/packages/deliverables/tool-present/README.md +++ b/packages/deliverables/tool-present/README.md @@ -29,7 +29,7 @@ Shared output and file-link guidance comes from [`dsh-deliverable-file-reference `present` validates paths against the Session's current working directory and records absolute paths, so later directory changes do not redirect delivered files. -The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call. Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access. +The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call with `FsError` codes such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, and `FS_PERMISSION_DENIED`; an empty path or a file count outside 1 to `maxFiles` reports `ToolArgsError` (`INVALID_ARGS`), and a call without an agent Session or open turn reports `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`). Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access. Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available: diff --git a/packages/deliverables/tool-present/README.zh.md b/packages/deliverables/tool-present/README.zh.md index 40b81571aa..08410efe05 100644 --- a/packages/deliverables/tool-present/README.zh.md +++ b/packages/deliverables/tool-present/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" `present` 按 Session 的当前工作目录校验路径并记录绝对路径,因此后续目录切换不会让已交付文件指向其他位置。 -`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用失败。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置。 +`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用以 `FsError` 失败,错误码如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 与 `FS_PERMISSION_DENIED`;路径为空或文件数量不在 1 到 `maxFiles` 之间时报告 `ToolArgsError`(`INVALID_ARGS`),缺少 agent Session 或打开的 turn 时报告 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`)。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置。 在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影: diff --git a/packages/deliverables/tool-present/src/index.ts b/packages/deliverables/tool-present/src/index.ts index 393a95d8c8..fac6ab0378 100644 --- a/packages/deliverables/tool-present/src/index.ts +++ b/packages/deliverables/tool-present/src/index.ts @@ -3,7 +3,7 @@ import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' -import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, ToolContextUnavailableError, defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' import type { PresentedFile } from './types.ts' @@ -76,21 +76,21 @@ export function apply(ctx: Context, config: Config): void { render: (_args, value) => [{ type: 'text', text: value.files.map(file => `Presented ${file.path}`).join('\n') }], }, async execute(args, exec) { - if (exec.agent === undefined) throw new Error('present requires an agent Session') + if (exec.agent === undefined) throw new ToolContextUnavailableError('present requires an agent Session') const boundary = ctx.agents.turnBoundaryOf(exec.agent.session) - if (boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') - if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`) + if (boundary.openTurnStartSeq === null) throw new ToolContextUnavailableError('present requires an open turn') + if (args.files.length === 0 || args.files.length > config.maxFiles) throw new ToolArgsError([`present accepts 1 to ${config.maxFiles} files`]) const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal) const options = { cwd, signal: exec.signal } const files: PresentedFile[] = [] for (const file of args.files) { - if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path') + if (file.path.trim().length === 0) throw new ToolArgsError(['present requires a non-empty file path']) const entry = await ctx.fs.lstat(file.path, { cwd }, exec.signal) - if (entry !== undefined && entry.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) + if (entry !== undefined && entry.type !== 'file') throw new FsError(`Cannot present ${file.path}: not a regular file`, 'FS_NOT_REGULAR_FILE') const target = await ctx.fs.resolve(file.path, options) const info = await ctx.fs.stat(target, exec.signal) if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND') - if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) + if (info.type !== 'file') throw new FsError(`Cannot present ${file.path}: not a regular file`, 'FS_NOT_REGULAR_FILE') files.push({ ...file, path: ctx.fs.processPath(target) }) } exec.signal.throwIfAborted() diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index 18da272027..6492740c99 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -140,9 +140,19 @@ describe('present', () => { const { root, owner, execute } = await setup() await writeFile(join(root, 'large'), 'four') await symlink(tmpdir(), join(root, 'outside')) - for (const files of [[], [{ path: '' }], [{ path: 'missing' }], [{ path: '.' }], [{ path: 'outside' }], [{ path: 'large' }, { path: 'large' }, { path: 'large' }]]) { + const invalidArgs = { name: 'ToolArgsError', code: 'INVALID_ARGS' } + const notRegular = { name: 'FsError', code: 'FS_NOT_REGULAR_FILE' } + for (const [files, info] of [ + [[], invalidArgs], + [[{ path: '' }], invalidArgs], + [[{ path: 'missing' }], { name: 'FsError', code: 'FS_NOT_FOUND' }], + [[{ path: '.' }], notRegular], + [[{ path: 'outside' }], notRegular], + [[{ path: 'large' }, { path: 'large' }, { path: 'large' }], invalidArgs], + ] as const) { const result = await execute(files) expect(result.isError, JSON.stringify(files)).toBe(true) + expect(result.error?.info, JSON.stringify(files)).toEqual(info) } expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) }) @@ -163,9 +173,13 @@ it('validates deployment limits before registering the tool', async () => { it('requires an agent, an open turn, and a workspace', async () => { const { ctx, owner, execute } = await setup() const detached = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('detached'), name: 'present', arguments: { files: [{ path: 'a' }] } }) + const contextUnavailable = { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } expect(detached.isError).toBe(true) + expect(detached.error?.info).toEqual(contextUnavailable) owner.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect((await execute([{ path: 'a' }])).isError).toBe(true) + const closed = await execute([{ path: 'a' }]) + expect(closed.isError).toBe(true) + expect(closed.error?.info).toEqual(contextUnavailable) const noWorkspace = await agent(ctx, undefined) noWorkspace.session.append('turn/start', { turn: 1 }) const absent = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace }) @@ -188,7 +202,9 @@ it('refuses a final symlink to an ordinary file', async () => { const { root, execute } = await setup() await writeFile(join(root, 'source'), 'source') await symlink(join(root, 'source'), join(root, 'link')) - expect((await execute([{ path: 'link' }])).isError).toBe(true) + const result = await execute([{ path: 'link' }]) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }) }) @@ -197,5 +213,7 @@ it('refuses a file replaced by a directory after inspecting its final component' await writeFile(join(root, 'source'), 'source') const directory = await ctx.fs.stat(await ctx.fs.resolve(root)) vi.spyOn(ctx.fs, 'stat').mockResolvedValueOnce(directory) - expect((await execute([{ path: 'source' }])).isError).toBe(true) + const result = await execute([{ path: 'source' }]) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }) }) From ea2e1163c088292e25558527477cddff5bb63750 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:30:56 +0800 Subject: [PATCH 06/81] feat(tool-fs): add ImageInputError for read_image failures read_image refusals throw ImageInputError with a closed code list (IMAGE_UNSUPPORTED_MODEL, IMAGE_UNSUPPORTED_FORMAT, IMAGE_TYPE_MISMATCH, IMAGE_INVALID, IMAGE_TOO_LARGE, IMAGE_CONVERSION_FAILED, IMAGE_STORE_FAILED). Attachment-store rejections are classified at the tool boundary, keeping the AttachmentError as cause; HarnessError and AbortError pass through. An empty file_path throws ToolArgsError, and a missing model route or attachment service throws ToolContextUnavailableError. The README records that argument-check messages now carry the ToolArgsError "invalid arguments: " prefix. --- packages/fs/tool-fs/README.i18n.yaml | 12 +- packages/fs/tool-fs/README.md | 7 +- packages/fs/tool-fs/README.zh.md | 7 +- packages/fs/tool-fs/src/image-error.ts | 46 +++++ packages/fs/tool-fs/src/index.ts | 2 + packages/fs/tool-fs/src/read-image.ts | 144 ++++++++++------ packages/fs/tool-fs/tests/read-image.spec.ts | 163 +++++++++++++----- .../read-image-text-route/session.v4.jsonl | 2 +- 8 files changed, 274 insertions(+), 109 deletions(-) create mode 100644 packages/fs/tool-fs/src/image-error.ts diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index a421441ff4..15125f4f7e 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -30,8 +30,8 @@ en: 9bddebaf4aff0a8d zh: fea0196162e3fcd1 /deepseek-ai-dsh-tool-fs/use-this-package/failures-and-recovery: - en: f7c7834de37e1ece - zh: ff08efcf7c08379a + en: 177d25a1e8f7afbd + zh: 0303980d6b2789c3 /deepseek-ai-dsh-tool-fs/understand-the-implementation: en: e30f7038c28e93f8 zh: a3868dbd90367b13 @@ -39,8 +39,8 @@ en: 1f4d7213aa3d0d60 zh: aa6573f96c8b5220 /deepseek-ai-dsh-tool-fs/understand-the-implementation/source-map: - en: 459b773aafc6c163 - zh: ccd729aded7d98ba + en: 791043de5ce46710 + zh: c5315d0195908f83 /deepseek-ai-dsh-tool-fs/understand-the-implementation/per-tool-flow: en: 35fbee1ef9811806 zh: 17c725e2b742c073 @@ -126,8 +126,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-fs/model-experience/tool-errors/what-the-model-sees: - en: 4e8fac7bac8ced61 - zh: 7ba921dbcddc9f97 + en: 816a5ac724377eeb + zh: 6833892230a1264c /deepseek-ai-dsh-tool-fs/model-experience/tool-errors/token-effect: en: 681cadd5be7b8ac1 zh: 65e483f62fb819d9 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index aabec263d2..d7e519d577 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -83,7 +83,9 @@ With the policy plugin mounted, `write` and `edit` obtain their guard from the ` For `apply_patch`, syntax and preflight refusals throw with `isError: true` before any disk change. `FsError` preserves filesystem codes; `PatchToolError extends HarnessError` carries `PATCH_SYNTAX` and `PATCH_SYMLINK_SOURCE`. Once commit starts, the tool returns a value with `isError: false` even on failure or uncertainty, so known partial effects retain their diff metadata; a call cancelled during commit receives the tool registry's abort result like every other tool, and its completed changes remain visible through Review. `phase`, `cancelled`, ordered `operations` and `affectedPaths` describe the commit; each operation retains its index, kind, display paths, patch line, status, diagnostic and known changes. Full `before`/`after` text serves rendering and PTC; the Session stores the rendered result plus `meta.diffs` and `meta.stopped`. Creations store no diff, as for `write`; deletions and move sources store a removal diff only when the provider returned a bounded basis, using `newText: ''` because `FileDiff.newText` cannot be null. -Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include `file_path must be a non-empty string`, `limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. `FS_NOT_OBSERVED` is normalized to `cannot modify "": file has not been read — read the file, then retry`, independent of whether the policy or provider rejected the operation; `FS_STALE_VERSION` retains the provider's reason and appends `— re-read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. +`read_image` refusals throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model), `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file), `IMAGE_TYPE_MISMATCH` (rename or convert the file), `IMAGE_INVALID` (truncated or corrupt bytes), `IMAGE_TOO_LARGE` (downscale the image), `IMAGE_CONVERSION_FAILED` (a 16-bit PNG could not be normalized; convert it to 8-bit), and `IMAGE_STORE_FAILED` (any other attachment-store failure, kept as `cause`). An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; an `AbortError` or another `HarnessError` from the store passes through unchanged. + +Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include the `ToolArgsError` (`INVALID_ARGS`) messages `invalid arguments: file_path must be a non-empty string` and `invalid arguments: limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. `FS_NOT_OBSERVED` is normalized to `cannot modify "": file has not been read — read the file, then retry`, independent of whether the policy or provider rejected the operation; `FS_STALE_VERSION` retains the provider's reason and appends `— re-read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. ----- @@ -106,6 +108,7 @@ The tools are the executor; policy is an event gate. The tools inject no policy | [`src/index.ts`](src/index.ts) | Plugin entry: `Config`, tool composition, `read_image` attachments gate | | [`src/read.ts`](src/read.ts) | `read` executor: one stat, streaming decision, window build, observation | | [`src/read-image.ts`](src/read-image.ts) | `read_image` executor: route and media-type gates, bounded bytes, attachment save | +| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` and its closed `read_image` failure codes | | [`src/write.ts`](src/write.ts) | `write` executor: intent waterfall, atomic write, observation | | [`src/apply-patch.ts`](src/apply-patch.ts), [`apply-patch-output.ts`](src/apply-patch-output.ts) | `apply_patch` registration, strict commit values and compact diagnostics | | [`src/apply-patch-presentation.ts`](src/apply-patch-presentation.ts) | Pure call previews and known result diffs | @@ -246,7 +249,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`, `cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`, `cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats` (an extension-less mismatch reports `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`). A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. The model-facing error wrapper normalizes every `FS_NOT_OBSERVED` source to `cannot modify "": file has not been read — read the file, then retry`; `FS_STALE_VERSION` keeps the provider's reason and adds `— re-read the file, then retry`. Both retain the structured error code and original cause. After that reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation messages are `ToolArgsError` (`INVALID_ARGS`) messages prefixed with `invalid arguments: `: `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, and `old_string and new_string must differ`. Its stable read messages are `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`, `cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`, `cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats` (an extension-less mismatch reports `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`). A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. The model-facing error wrapper normalizes every `FS_NOT_OBSERVED` source to `cannot modify "": file has not been read — read the file, then retry`; `FS_STALE_VERSION` keeps the provider's reason and adds `— re-read the file, then retry`. Both retain the structured error code and original cause. After that reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 2f5f153ed0..7e81223b57 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -83,7 +83,9 @@ kind: "package-reference" `apply_patch` 的语法与预检拒绝在磁盘变更前抛错,结果为 `isError: true`。`FsError` 保留文件系统错误码;`PatchToolError extends HarnessError` 携带 `PATCH_SYNTAX` 和 `PATCH_SYMLINK_SOURCE`。进入提交后,即使失败或结果不确定也返回 `isError: false` 的值,确保已知部分效果的 diff 元数据得以保留;提交期间被取消的调用与其他工具一样收到工具注册表的中止结果,已完成的变更仍可通过 Review 查看。`phase`、`cancelled`、有序 `operations` 与 `affectedPaths` 描述提交;每条操作保留索引、种类、展示路径、补丁行号、状态、诊断与已知变更。完整 `before`/`after` 文本供渲染和 PTC 使用;Session 保存渲染文本以及 `meta.diffs` 与 `meta.stopped`。新建与 `write` 一样不保存 diff;删除与移动源仅在提供方返回有界基线时保存删除 diff,并使用 `newText: ''`,因为 `FileDiff.newText` 不可为 null。 -失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `file_path must be a non-empty string`、`limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。无论拒绝来自策略还是提供方,`FS_NOT_OBSERVED` 都规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 +`read_image` 的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)、`IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)、`IMAGE_TYPE_MISMATCH`(重命名或转换文件)、`IMAGE_INVALID`(字节被截断或损坏)、`IMAGE_TOO_LARGE`(缩小图像)、`IMAGE_CONVERSION_FAILED`(16 位 PNG 无法规范化;转换为 8 位)以及 `IMAGE_STORE_FAILED`(其他附件存储失败,原错误保留为 `cause`)。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的 `AbortError` 或其他 `HarnessError` 原样传递。 + +失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `ToolArgsError`(`INVALID_ARGS`)消息 `invalid arguments: file_path must be a non-empty string` 与 `invalid arguments: limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。无论拒绝来自策略还是提供方,`FS_NOT_OBSERVED` 都规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 ----- @@ -106,6 +108,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:`Config`、工具组合、`read_image` 附件门禁 | | [`src/read.ts`](src/read.ts) | `read` 执行器:一次 stat、流式决策、窗口构建、观察 | | [`src/read-image.ts`](src/read-image.ts) | `read_image` 执行器:路由与媒体类型门禁、有界字节、附件保存 | +| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` 及其封闭的 `read_image` 失败码 | | [`src/write.ts`](src/write.ts) | `write` 执行器:意图 waterfall、原子写入、观察 | | [`src/apply-patch.ts`](src/apply-patch.ts)、[`apply-patch-output.ts`](src/apply-patch-output.ts) | `apply_patch` 注册、严格提交值与精简诊断 | | [`src/apply-patch-presentation.ts`](src/apply-patch-presentation.ts) | 纯调用预览与已知结果 diff | @@ -246,7 +249,7 @@ Read a file before editing it unless you just created or edited it in this sessi #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`、`cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`、`cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`(无扩展名路径的不匹配报告 `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`)。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。模型侧错误包装把所有 `FS_NOT_OBSERVED` 来源规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。两者都保留结构化错误码和原始原因。该次重新读取确认缺失后,`edit` 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;`write` 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验消息是带 `invalid arguments: ` 前缀的 `ToolArgsError`(`INVALID_ARGS`)消息:`file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string` 与 `old_string and new_string must differ`。稳定的读取消息是 `cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`、`cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`、`cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`(无扩展名路径的不匹配报告 `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`)。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。模型侧错误包装把所有 `FS_NOT_OBSERVED` 来源规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。两者都保留结构化错误码和原始原因。该次重新读取确认缺失后,`edit` 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;`write` 则使用带防护的创建。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/image-error.ts b/packages/fs/tool-fs/src/image-error.ts new file mode 100644 index 0000000000..102942e9c6 --- /dev/null +++ b/packages/fs/tool-fs/src/image-error.ts @@ -0,0 +1,46 @@ +/** Typed `read_image` failures. @module @deepseek-ai/dsh-tool-fs/src/image-error */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Every machine-routable `read_image` failure code: + * + * - `IMAGE_UNSUPPORTED_MODEL`: the calling route does not declare image input; switch to an image-capable model. + * - `IMAGE_UNSUPPORTED_FORMAT`: the extension, the file signature, or the deployment's accepted media types + * exclude the format; convert the file to an accepted format or read another file. + * - `IMAGE_TYPE_MISMATCH`: the extension or file signature declares one format but the bytes decode as another; rename or convert the file. + * - `IMAGE_INVALID`: bytes in a supported format do not decode; the file is truncated or corrupt. + * - `IMAGE_TOO_LARGE`: a side, the decoded pixel count, or the stored byte budget exceeds the deployment limit; + * downscale the image and read the smaller copy. + * - `IMAGE_CONVERSION_FAILED`: a 16-bit PNG could not be normalized to 8-bit sRGB; convert it to an 8-bit format. + * - `IMAGE_STORE_FAILED`: the attachment store could not commit the image for another reason; report the cause + * instead of retrying unchanged. + */ +export const IMAGE_INPUT_ERROR_CODES = [ + 'IMAGE_UNSUPPORTED_MODEL', + 'IMAGE_UNSUPPORTED_FORMAT', + 'IMAGE_TYPE_MISMATCH', + 'IMAGE_INVALID', + 'IMAGE_TOO_LARGE', + 'IMAGE_CONVERSION_FAILED', + 'IMAGE_STORE_FAILED', +] as const + +/** One `read_image` failure code. */ +export type ImageInputErrorCode = typeof IMAGE_INPUT_ERROR_CODES[number] + +/** Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. */ +export class ImageInputError extends HarnessError { + override readonly code: ImageInputErrorCode + + /** + * @param message - model-facing refusal naming the path and the safe next step. + * @param code - stable failure code. + * @param options - standard `cause` chaining. + */ + constructor(message: string, code: ImageInputErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'ImageInputError' + this.code = code + } +} diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 41e3be251c..9e3e3095d5 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -17,6 +17,8 @@ import { applyReadImageTool } from './read-image.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' import { FsSandboxController } from './sandbox.ts' +export { ImageInputError, type ImageInputErrorCode } from './image-error.ts' + /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 4a452e21c4..b8bacbcba4 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -16,9 +16,12 @@ import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { ImageInputError } from './image-error.ts' import { resolveRegularReadTarget } from './read-target.ts' /** Extensions `read_image` accepts; magic-byte validation at the attachment service stays authoritative. */ @@ -116,6 +119,8 @@ export function imageMediaTypeForPath(filePath: string): ImageMediaType | undefi * @param ctx - the plugin context used to resolve the optional `llm` service. * @param exec - the tool-execution context supplying the calling agent. * @param requestedPath - the raw, not-yet-resolved path rendered in refusal messages. + * @throws ToolContextUnavailableError when no Agent route or `llm` service is available. + * @throws ImageInputError `IMAGE_UNSUPPORTED_MODEL` when the route does not declare image input. */ export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, requestedPath: string): Promise { const routed = exec.agent?.session.requestHeader()?.config @@ -123,18 +128,21 @@ export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, const model = routed?.model ?? exec.agent?.options.model const llm = ctx.get('llm') if (provider === undefined || model === undefined || llm === undefined) { - throw new Error(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`) + throw new ToolContextUnavailableError(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`) } const active = await llm.resolveModelInfo(provider, model, exec.signal) if (active.inputModalities === undefined || !active.inputModalities.includes('image')) { - throw new Error(`cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`) + throw new ImageInputError( + `cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`, + 'IMAGE_UNSUPPORTED_MODEL', + ) } } /** Refuse a media type outside the deployment's accepted set, naming the offending path. */ function assertDeploymentAccepts(attachments: AttachmentStore, mediaType: ImageMediaType, displayPath: string): void { if (!attachments.imageLimits.mediaTypes.includes(mediaType)) { - throw new Error(`cannot read "${displayPath}": ${mediaType} images are not accepted by this deployment`) + throw new ImageInputError(`cannot read "${displayPath}": ${mediaType} images are not accepted by this deployment`, 'IMAGE_UNSUPPORTED_FORMAT') } } @@ -197,6 +205,72 @@ function imageReadContent(value: ImageReadValue): ContentBlock[] { ] } +/** Facts about one image read that a save-failure report names. */ +interface SaveFailureFacts { + displayPath: string + /** Lower-cased path extension; empty for an extension-less path. */ + extension: string + mediaType: ImageMediaType + /** Whether `mediaType` came from the file signature instead of the extension. */ + sniffed: boolean +} + +/** + * Classify one `saveImage` rejection as the error `read_image` throws. + * An `AbortError` and an already classified `HarnessError` pass through + * unchanged; attachment admission codes become {@link ImageInputError} codes, + * and every other failure becomes `IMAGE_STORE_FAILED` with the original as `cause`. + * @param error - the value `saveImage` rejected with. + * @param attachments - the store whose limits the refusal names. + * @param facts - the path and media-type facts the refusal names. + * @returns the error to throw from the tool body. + */ +function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFailureFacts): unknown { + if (error instanceof HarnessError || (error instanceof Error && error.name === 'AbortError')) return error + const { displayPath, extension, mediaType, sniffed } = facts + const refusal = (detail: string, code: ImageInputError['code']) => + new ImageInputError(`cannot read "${displayPath}": ${detail}`, code, { cause: error }) + const storeFailed = (detail: string) => refusal(`the attachment store could not save the image: ${detail}`, 'IMAGE_STORE_FAILED') + if (!(error instanceof AttachmentError)) return storeFailed(error instanceof Error ? error.message : String(error)) + // Size refusals stay recoverable tool errors: an oversized image must never + // enter durable history, where it would ride every later model request past + // provider-side dimension rejections. + switch (error.code) { + case 'IMAGE_DIMENSION_TOO_LARGE': + return refusal(`at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`, 'IMAGE_TOO_LARGE') + case 'IMAGE_TOO_MANY_PIXELS': + return refusal(`the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`, 'IMAGE_TOO_LARGE') + case 'IMAGE_TOO_LARGE': + case 'IMAGES_TOO_LARGE': + return refusal('the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy', 'IMAGE_TOO_LARGE') + case 'UNSUPPORTED_IMAGE_TYPE': + return refusal(`${mediaType} images are not accepted by this deployment`, 'IMAGE_UNSUPPORTED_FORMAT') + case 'INVALID_IMAGE': + return refusal('the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt', 'IMAGE_INVALID') + case 'IMAGE_TYPE_MISMATCH': + return sniffed + ? refusal(`the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt`, 'IMAGE_TYPE_MISMATCH') + : refusal(`the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, 'IMAGE_TYPE_MISMATCH') + case 'ATTACHMENT_WRITE_FAILED': + return /16-bit PNG/iu.test(error.message) + ? refusal('the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry', 'IMAGE_CONVERSION_FAILED') + : storeFailed(error.message) + case 'TOO_MANY_IMAGES': + case 'INVALID_IMAGE_BASE64': + case 'INVALID_FILE_BASE64': + case 'INVALID_ATTACHMENT_REF': + case 'ATTACHMENT_CORRUPT': + case 'ATTACHMENT_NOT_FOUND': + case 'ATTACHMENT_READ_FAILED': + case 'ATTACHMENT_PROJECTION_UNSUPPORTED': + case 'ATTACHMENT_FILES_UNSUPPORTED': + return storeFailed(error.message) + /* v8 ignore next -- Closed AttachmentErrorCode union; assertNever checks exhaustiveness at compile time. */ + default: + return assertNever(error.code) + } +} + /** * Register the `read_image` tool into the given context. The composing plugin * owns the attachments gate: `src/index.ts` calls this inside @@ -238,7 +312,7 @@ export function applyReadImageTool(ctx: Context): void { // of the same file cannot conflict. isConcurrencySafe: () => true, async execute(args, exec) { - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) // Every pre-read gate runs before any filesystem I/O so a refusal never // leaks partial reads or attachment writes. An extension-less path @@ -247,11 +321,14 @@ export function applyReadImageTool(ctx: Context): void { const extension = extname(args.file_path).toLowerCase() const declared = imageMediaTypeForPath(args.file_path) if (declared === undefined && extension !== '') { - throw new Error(`cannot read "${args.file_path}": the ${extension} extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`) + throw new ImageInputError( + `cannot read "${args.file_path}": the ${extension} extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`, + 'IMAGE_UNSUPPORTED_FORMAT', + ) } const attachments = ctx.get('attachments') if (attachments === undefined) { - throw new Error(`cannot read "${args.file_path}" as an image: no attachment service is mounted`) + throw new ToolContextUnavailableError(`cannot read "${args.file_path}" as an image: no attachment service is mounted`) } if (declared !== undefined) assertDeploymentAccepts(attachments, declared, args.file_path) await assertImageCapableRoute(ctx, exec, args.file_path) @@ -264,7 +341,10 @@ export function applyReadImageTool(ctx: Context): void { const data = await ctx.fs.readBytes(target, exec.signal, byteCap) const mediaType = declared ?? sniffImageMediaType(data) if (mediaType === undefined) { - throw new Error(`cannot read "${target.displayPath}": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`) + throw new ImageInputError( + `cannot read "${target.displayPath}": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`, + 'IMAGE_UNSUPPORTED_FORMAT', + ) } if (declared === undefined) assertDeploymentAccepts(attachments, mediaType, target.displayPath) // Persist before returning: the image block must reference a durably @@ -273,51 +353,9 @@ export function applyReadImageTool(ctx: Context): void { try { ref = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) } catch (error: unknown) { - if (!(error instanceof AttachmentError)) throw error - // Dimension refusals stay recoverable tool errors: an oversized image - // must never enter durable history, where it would ride every later - // model request past provider-side dimension rejections. - if (error.code === 'IMAGE_DIMENSION_TOO_LARGE') { - throw new Error( - `cannot read "${target.displayPath}": at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`, - { cause: error }, - ) - } - if (error.code === 'IMAGE_TOO_MANY_PIXELS') { - throw new Error( - `cannot read "${target.displayPath}": the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`, - { cause: error }, - ) - } - if (error.code === 'IMAGE_TOO_LARGE') { - throw new Error( - `cannot read "${target.displayPath}": the image cannot be stored within the deployment's byte limits; downscale the image and read the smaller copy`, - { cause: error }, - ) - } - if (error.code === 'ATTACHMENT_WRITE_FAILED' && /16-bit PNG/iu.test(error.message)) { - throw new Error( - `cannot read "${target.displayPath}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, - { cause: error }, - ) - } - if (error.code === 'INVALID_IMAGE' && declared === undefined) { - throw new Error( - `cannot read "${target.displayPath}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, - { cause: error }, - ) - } - if (error.code !== 'IMAGE_TYPE_MISMATCH') throw error - if (declared === undefined) { - throw new Error( - `cannot read "${target.displayPath}": the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt`, - { cause: error }, - ) - } - throw new Error( - `cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, - { cause: error }, - ) + throw saveFailure(error, attachments, { + displayPath: target.displayPath, extension, mediaType, sniffed: declared === undefined, + }) } ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec) const value: ImageReadValue = { diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 97087edf7e..6c2feac7a4 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -26,6 +26,7 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import { AttachmentError, AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { FsError } from '@deepseek-ai/dsh-fs' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { @@ -372,6 +373,7 @@ describe('extension-less paths', () => { const trailingDot = await readImage(ctx, { file_path: 'foo.' }, agentOn('vision-model')) expect(trailingDot.isError).toBe(true) expect(text(trailingDot)).toContain('cannot read "foo.": the . extension does not declare a supported image format') + expect(trailingDot.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) const dotfile = await readImage(ctx, { file_path: '.hidden' }, agentOn('vision-model')) expect(dotfile.isError).toBe(false) @@ -386,6 +388,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'notes' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain(`cannot read "${join(dir, 'notes')}": the file content is not a supported image format`) + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) }) it('explains extension-less bytes that sniff as an image but do not decode', async () => { @@ -394,6 +397,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'broken' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('do not decode as a supported PNG/JPEG/WebP/GIF image') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_INVALID' }) }) it('applies the deployment media-type policy to the sniffed format', async () => { @@ -426,6 +430,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'avatar' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('image/png images are not accepted by this deployment') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) }) it('names a signature/decoded-format disagreement on an extension-less path', async () => { @@ -458,6 +463,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'sniffed' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('the file signature claims image/png, but the bytes decode as a different image format') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TYPE_MISMATCH' }) }) }) @@ -484,6 +490,7 @@ describe('strict image-modality gate', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn(model)) expect(result.isError).toBe(true) expect(text(result)).toContain('does not declare image input') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_MODEL' }) }) it('refuses when the route cannot be resolved (no agent, or no header and no options)', async () => { @@ -492,10 +499,12 @@ describe('strict image-modality gate', () => { const noAgent = await readImage(ctx, { file_path: 'red.png' }) expect(noAgent.isError).toBe(true) expect(text(noAgent)).toContain('route could not be resolved') + expect(noAgent.error?.info).toEqual({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' }) const noRoute = await readImage(ctx, { file_path: 'red.png' }, agentOn(undefined)) expect(noRoute.isError).toBe(true) expect(text(noRoute)).toContain('route could not be resolved') + expect(noRoute.error?.info).toEqual({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' }) }) it('refuses when no llm service is mounted', async () => { @@ -504,6 +513,7 @@ describe('strict image-modality gate', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('route could not be resolved') + expect(result.error?.info).toEqual({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' }) }) }) @@ -512,11 +522,13 @@ describe('argument and service preconditions', () => { const ctx = await setup() const empty = await readImage(ctx, { file_path: ' ' }, agentOn('vision-model')) expect(empty.isError).toBe(true) - expect(text(empty)).toContain('non-empty') + expect(text(empty)).toContain('file_path must be a non-empty string') + expect(empty.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) const nonImage = await readImage(ctx, { file_path: 'notes.txt' }, agentOn('vision-model')) expect(nonImage.isError).toBe(true) expect(text(nonImage)).toContain('the .txt extension does not declare a supported image format') + expect(nonImage.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) }) it.each([{}, { toolFs: { read: false, write: false, edit: false } }] satisfies SetupOptions[]) @@ -537,6 +549,7 @@ describe('argument and service preconditions', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('no attachment service is mounted') + expect(result.error?.info).toEqual({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' }) }) it('refuses a media type the deployment does not accept', async () => { @@ -568,6 +581,7 @@ describe('argument and service preconditions', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('image/png images are not accepted by this deployment') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_UNSUPPORTED_FORMAT' }) }) }) @@ -579,6 +593,7 @@ describe('image admission failures', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('the .jpg extension declares image/jpeg') expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TYPE_MISMATCH' }) }) it('caps an extension-less read at maxImageBytes before format detection', async () => { @@ -587,6 +602,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'red' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('exceeds') + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_TOO_LARGE' }) }) it('honors the tighter per-message aggregate byte bound', async () => { @@ -595,6 +611,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('exceeds') + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_TOO_LARGE' }) }) it('surfaces the pixel limit from the attachment admission', async () => { @@ -603,6 +620,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('exceeds the 4-pixel decoded-size limit') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TOO_LARGE' }) expect(text(result)).toContain('downscale the image and read the smaller copy') }) @@ -612,61 +630,114 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'wide.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('at least one image side exceeds the 2px limit') + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TOO_LARGE' }) expect(text(result)).toContain('downscale the image and read the smaller copy') }) - it('passes storage faults and non-attachment failures through unchanged', async () => { - /** Store whose commit fails with a configurable error; admission itself passes. */ - class FailingStore extends AttachmentStore { - static failure: unknown - readonly imageLimits: ImageAttachmentLimits = Object.freeze({ - maxImageBytes: 1024, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1024, - maxImagePixels: 100, - maxImageDimension: 2000, - mediaTypes: Object.freeze(['image/png'] as const), - }) + /** Store whose commit runs a configurable failure; admission itself passes. */ + class FailingStore extends AttachmentStore { + static failure: () => unknown = () => new Error('unset') + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + maxImageDimension: 2000, + mediaTypes: Object.freeze(['image/png'] as const), + }) - validateImage(_input: SaveImageAttachment): Promise { - return Promise.resolve() - } - - async saveImage(_input: SaveImageAttachment): Promise { - throw FailingStore.failure - } - - readImage(_ref: ImageAttachmentRef): Promise { - throw new Error('unreachable in this test') - } + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() } - await writeFile(join(dir, 'red.png'), PNG_1X1) + + async saveImage(_input: SaveImageAttachment): Promise { + throw FailingStore.failure() + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + + async function failingSave(failure: () => unknown, file = 'red.png', signal = testToolSignal) { + await writeFile(join(dir, file), PNG_1X1) const ctx = await setup({ attachments: false }) await ctx.plugin(FailingStore) + FailingStore.failure = failure + return executeToolFixture(ctx, { + signal, + callId: ToolCallId(`img-call-${++callCounter}`), + name: 'read_image', + arguments: { file_path: file }, + agent: agentOn('vision-model') as never, + }) + } - FailingStore.failure = new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED') - const storageFault = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) - expect(storageFault.isError).toBe(true) - expect(text(storageFault)).toContain('Unable to persist image attachment.') + it.each([ + ['IMAGE_TOO_LARGE', 'Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE', + 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy'], + ['IMAGES_TOO_LARGE', 'Image batch exceeds the configured aggregate image-byte limit.', 'IMAGE_TOO_LARGE', + 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy'], + ['UNSUPPORTED_IMAGE_TYPE', 'Image type image/png is not accepted by this deployment.', 'IMAGE_UNSUPPORTED_FORMAT', + 'image/png images are not accepted by this deployment'], + ['INVALID_IMAGE', 'Unsupported or malformed image data.', 'IMAGE_INVALID', + 'the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt'], + ['ATTACHMENT_WRITE_FAILED', 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', 'IMAGE_CONVERSION_FAILED', + 'the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry'], + ['ATTACHMENT_WRITE_FAILED', 'Unable to persist attachment.', 'IMAGE_STORE_FAILED', + 'the attachment store could not save the image: Unable to persist attachment.'], + ...([ + 'TOO_MANY_IMAGES', 'INVALID_IMAGE_BASE64', 'INVALID_FILE_BASE64', 'INVALID_ATTACHMENT_REF', 'ATTACHMENT_CORRUPT', + 'ATTACHMENT_NOT_FOUND', 'ATTACHMENT_READ_FAILED', 'ATTACHMENT_PROJECTION_UNSUPPORTED', 'ATTACHMENT_FILES_UNSUPPORTED', + ] as const).map(code => [code, 'Stored attachment failed.', 'IMAGE_STORE_FAILED', + 'the attachment store could not save the image: Stored attachment failed.'] as const), + ] as const)('classifies AttachmentError %s (%s) as %s', async (attachmentCode, message, code, detail) => { + const cause = new AttachmentError(message, attachmentCode) + const result = await failingSave(() => cause) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'ImageInputError', code }) + expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": ${detail}`) + }) - FailingStore.failure = new AttachmentError( - 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', - 'ATTACHMENT_WRITE_FAILED', - ) - const sixteenBit = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) - expect(text(sixteenBit)).toContain( - `cannot read "${join(dir, 'red.png')}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, - ) + it('wraps a non-attachment save failure as IMAGE_STORE_FAILED', async () => { + const result = await failingSave(() => new Error('unrelated infrastructure failure')) + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) + expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": the attachment store could not save the image: unrelated infrastructure failure`) - FailingStore.failure = new AttachmentError('Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE') - const overBudget = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) - expect(overBudget.isError).toBe(true) - expect(text(overBudget)).toContain('cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy') + const nonError = await failingSave(() => 'string failure') + expect(nonError.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) + expect(text(nonError)).toContain('the attachment store could not save the image: string failure') - FailingStore.failure = new Error('unrelated infrastructure failure') - const unrelated = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) - expect(unrelated.isError).toBe(true) - expect(text(unrelated)).toContain('unrelated infrastructure failure') + }) + + it('passes a classified HarnessError and an AbortError through unchanged', async () => { + const fsFailure = await failingSave(() => new FsError('cannot read "x": permission denied', 'FS_PERMISSION_DENIED')) + expect(fsFailure.error?.info).toEqual({ name: 'FsError', code: 'FS_PERMISSION_DENIED' }) + expect(text(fsFailure)).toBe('Error: cannot read "x": permission denied') + + const abort = await failingSave(() => new DOMException('store stopped', 'AbortError')) + expect(text(abort)).toContain('store stopped') + expect(text(abort)).not.toContain('cannot read') + }) + + it('lets the registry classify an AbortError thrown after caller cancellation', async () => { + const controller = new AbortController() + const result = await failingSave(() => { + controller.abort() + return new DOMException('save cancelled', 'AbortError') + }, 'red.png', controller.signal) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'AbortError', code: 'ABORTED' }) + expect(text(result)).not.toContain('cannot read') + }) + + it('classifies a save failure that is not cancellation even after the caller aborts', async () => { + const controller = new AbortController() + const result = await failingSave(() => { + controller.abort() + return new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED') + }, 'red.png', controller.signal) + expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) }) it('reports a missing image file and a directory target through the fs vocabulary', async () => { @@ -677,11 +748,13 @@ describe('image admission failures', () => { const missing = await readImage(ctx, { file_path: 'absent.png' }, agentOn('vision-model')) expect(missing.isError).toBe(true) expect(text(missing)).toContain('not found') + expect(missing.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_FOUND' }) expect(observed).toEqual([{ path: join(dir, 'absent.png'), kind: 'absent' }]) const directory = await readImage(ctx, { file_path: 'folder.png' }, agentOn('vision-model')) expect(directory.isError).toBe(true) expect(text(directory)).toContain('not a regular file') + expect(directory.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }) }) it('omits the display name when the store returns a reference without one', async () => { diff --git a/snapshots/session/read-image-text-route/session.v4.jsonl b/snapshots/session/read-image-text-route/session.v4.jsonl index bd46aa46ef..3c4c5b47e6 100644 --- a/snapshots/session/read-image-text-route/session.v4.jsonl +++ b/snapshots/session/read-image-text-route/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Use read_image on red.png in","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699420816,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1788699420816,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}},{"type":"chunk","time":1788699420816,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699420816,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"read-image-refused"},"toolCallId":"read-image-refused","content":[{"type":"text","text":"Error: cannot read \"red.png\" as an image: model \"deepseek-v4-flash\" does not declare image input; switch to an image-capable model to read images"}],"isError":true,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"read-image-refused"},"toolCallId":"read-image-refused","content":[{"type":"text","text":"Error: cannot read \"red.png\" as an image: model \"deepseek-v4-flash\" does not declare image input; switch to an image-capable model to read images"}],"isError":true,"id":"{{message:5}}"},"error":{"name":"ImageInputError","code":"IMAGE_UNSUPPORTED_MODEL"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNAVAILABLE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699420834,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788699420834,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNAVAILABLE"}}},{"type":"chunk","time":1788699420834,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699420834,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From 360216b089c5ca29f1bb25cadb8a64bdb02c8974 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:36:18 +0800 Subject: [PATCH 07/81] feat(lsp): give lsp tool failures structured error codes tool-lsp argument checks throw ToolArgsError. lsp-stdio keeps FsError codes from resolution and reads (adding the LSP operand to the message), reports a missing or non-directory workspace and an oversized source as FsError, and adds LspError codes for the stdio provider: LSP_FILE_OUTSIDE_WORKSPACE, LSP_UNSUPPORTED_ENCODING, LSP_SPAWN_FAILED, LSP_SERVER_EXITED, LSP_PROTOCOL_ERROR, LSP_MESSAGE_TOO_LARGE, and LSP_SERVER_ERROR (JSON-RPC code and text in the message), and LSP_NOT_INITIALIZED for a query that finds no negotiated capabilities after a successful handshake. A query failure whose teardown also fails keeps the query's name and code. LspError assigns its literal name; the LSP subsystem page documents every code. --- docs/subsystems/lsp.i18n.yaml | 7 +- docs/subsystems/lsp.md | 27 ++++- docs/subsystems/lsp.zh.md | 27 ++++- packages/lsp/lsp-stdio/README.i18n.yaml | 4 +- packages/lsp/lsp-stdio/README.md | 2 +- packages/lsp/lsp-stdio/README.zh.md | 2 +- packages/lsp/lsp-stdio/src/abort.ts | 7 +- packages/lsp/lsp-stdio/src/connection.ts | 74 +++++++++++--- packages/lsp/lsp-stdio/src/framing.ts | 28 ++++-- packages/lsp/lsp-stdio/src/host.ts | 34 +++++-- packages/lsp/lsp-stdio/src/index.ts | 32 +++++- packages/lsp/lsp-stdio/src/instance.ts | 16 +-- packages/lsp/lsp-stdio/src/protocol.ts | 5 - packages/lsp/lsp-stdio/src/translate.ts | 30 +++++- .../lsp/lsp-stdio/tests/connection.spec.ts | 93 +++++++++++++++-- packages/lsp/lsp-stdio/tests/framing.spec.ts | 12 +-- packages/lsp/lsp-stdio/tests/host.spec.ts | 79 ++++++++++++--- packages/lsp/lsp-stdio/tests/instance.spec.ts | 57 ++++++++++- .../lsp/lsp-stdio/tests/lifecycle.spec.ts | 99 ++++++++++++++++--- .../lsp/lsp-stdio/tests/translate.spec.ts | 6 +- packages/lsp/lsp/src/index.ts | 21 +++- packages/lsp/tool-lsp/README.i18n.yaml | 4 +- packages/lsp/tool-lsp/README.md | 2 +- packages/lsp/tool-lsp/README.zh.md | 2 +- packages/lsp/tool-lsp/src/render.ts | 10 +- .../lsp/tool-lsp/tests/integration.spec.ts | 13 +++ packages/lsp/tool-lsp/tests/render.spec.ts | 10 +- packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 31 +++++- 28 files changed, 608 insertions(+), 126 deletions(-) diff --git a/docs/subsystems/lsp.i18n.yaml b/docs/subsystems/lsp.i18n.yaml index cae4151b07..6d21e0fc70 100644 --- a/docs/subsystems/lsp.i18n.yaml +++ b/docs/subsystems/lsp.i18n.yaml @@ -15,5 +15,8 @@ en: d260facb7ad22440 zh: f4868aae5be01eb6 /lsp-navigation/provider-and-service: - en: 754b4f1a0c80e332 - zh: aaacbc912dd6337b + en: 9977196522f2d7e5 + zh: f971ba2c60beda68 +/lsp-navigation/failures: + en: 192bd5699c1a23af + zh: f221bff65286231a diff --git a/docs/subsystems/lsp.md b/docs/subsystems/lsp.md index 5a8adb24c1..275ba3b236 100644 --- a/docs/subsystems/lsp.md +++ b/docs/subsystems/lsp.md @@ -162,7 +162,32 @@ interface LspService { } ``` -`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with stable codes such as `LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, `LSP_UNSUPPORTED_OPERATION`, and `LSP_MALFORMED_RESPONSE`, which callers route on instead of parsing `message`. +`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with the stable codes listed under [Failures](#failures), which callers route on instead of parsing `message`. + + +## Failures + +Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` and `code` the tool result keeps in `error.info`. `LspError` sets `name` to `LspError`; its `code` stays open because providers and consumers add codes, so consumers tolerate codes they do not know. Invalid tool arguments fail as `ToolArgsError` `INVALID_ARGS`, and caller cancellation as `AbortError`. Workspace and source reads keep the filesystem provider's `FsError` code, such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, or `FS_NOT_TEXT`, and prefix the LSP operand to its message; the stdio provider adds `FS_NOT_DIRECTORY` for a non-directory workspace and `FS_TOO_LARGE` for a source over `maxDocumentBytes`. A query failure whose server teardown also fails keeps the query failure's `name` and `code` and appends the teardown failure to its message. + +| Code | Raised by | Meaning | Consumer action | +|---|---|---|---| +| `LSP_INVALID_PROVIDER` | Seam | A registration has an empty id or language id, no extensions, or an invalid extension | Fix the registration | +| `LSP_CONFLICT` | Seam | The provider id or an extension is already registered | Remove the duplicate registration | +| `LSP_UNAVAILABLE` | Seam | No provider handles the file's extension | Query a supported file or add a provider | +| `LSP_WORKSPACE_REQUIRED` | `lsp` tool | The call has no Agent Session workspace | Call the tool from an Agent Session | +| `LSP_DISPOSED` | Provider | The provider or its server instance was disposed | Retry after the provider is mounted again | +| `LSP_UNSUPPORTED_OPERATION` | Provider | The server lacks the operation or transient open/close synchronization | Choose another operation or server | +| `LSP_MALFORMED_RESPONSE` | Provider | The server's `initialize` or query result violates the protocol | Report the server defect | +| `LSP_FILE_OUTSIDE_WORKSPACE` | Stdio provider | The source resolves outside the canonical workspace | Query a file inside the workspace | +| `LSP_UNSUPPORTED_ENCODING` | Stdio provider | The server negotiated a position encoding other than UTF-16 | Use a server that supports UTF-16 | +| `LSP_SPAWN_FAILED` | Stdio provider | The subprocess provider failed to launch or run the server; a provider `HarnessError` keeps its own `name` and `code` instead | Fix the server command or installation | +| `LSP_SERVER_EXITED` | Stdio provider | The server exited or closed its stdin; the message carries the retained stderr tail | Retry later; inspect the stderr tail when it repeats | +| `LSP_PROTOCOL_ERROR` | Stdio provider | The server's output broke base-protocol framing or JSON | Report the server defect | +| `LSP_MESSAGE_TOO_LARGE` | Stdio provider | A server message exceeded `maxMessageBytes` | Narrow the query or raise `maxMessageBytes` | +| `LSP_SERVER_ERROR` | Stdio provider | The server answered with a JSON-RPC error; the message carries its numeric code and text | Act on the server's message | +| `LSP_NOT_INITIALIZED` | Stdio provider | A query found the server instance without negotiated capabilities after its handshake succeeded; this is inconsistent internal state, not a server condition | Report the defect | + +After a transport failure (`LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`), the stdio provider retries the read-only query once on a fresh process when the failed process tears down cleanly. diff --git a/docs/subsystems/lsp.zh.md b/docs/subsystems/lsp.zh.md index e041217866..65612a8eac 100644 --- a/docs/subsystems/lsp.zh.md +++ b/docs/subsystems/lsp.zh.md @@ -162,7 +162,32 @@ interface LspService { } ``` -`LspProviderId` 是该 seam 的品牌化 id(来自 [dsh-brand](../../packages/util/brand) 的 `Branded<'LspProviderId'>`);`LspError` 扩展 `HarnessError`,提供 `LSP_INVALID_PROVIDER`、`LSP_CONFLICT`、`LSP_UNAVAILABLE`、`LSP_DISPOSED`、`LSP_UNSUPPORTED_OPERATION` 和 `LSP_MALFORMED_RESPONSE` 等稳定错误码,调用方应按错误码路由,而不是解析 `message`。 +`LspProviderId` 是该 seam 的品牌化 id(来自 [dsh-brand](../../packages/util/brand) 的 `Branded<'LspProviderId'>`);`LspError` 扩展 `HarnessError`,提供[失败](#failures)一节列出的稳定错误码,调用方应按错误码路由,而不是解析 `message`。 + + +## 失败 + +`lsp` 工具调用的每个预期失败都是 `HarnessError`,工具结果会把它的 `name` 和 `code` 保留在 `error.info` 中。`LspError` 把 `name` 设为 `LspError`;由于提供方和消费方会添加错误码,它的 `code` 保持开放,消费方须容忍未知错误码。无效的工具参数以 `ToolArgsError` `INVALID_ARGS` 失败,调用方取消以 `AbortError` 失败。工作区与源文件读取保留文件系统提供方的 `FsError` 错误码,例如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 或 `FS_NOT_TEXT`,并在其消息前加上 LSP 操作对象;stdio 提供方对非目录工作区使用 `FS_NOT_DIRECTORY`,对超过 `maxDocumentBytes` 的源文件使用 `FS_TOO_LARGE`。如果查询失败后服务器拆除也失败,错误保留查询失败的 `name` 和 `code`,并把拆除失败追加到消息中。 + +| 错误码 | 抛出方 | 含义 | 消费方操作 | +|---|---|---|---| +| `LSP_INVALID_PROVIDER` | Seam | 注册的 id 或语言 id 为空、没有扩展名,或扩展名无效 | 修正注册 | +| `LSP_CONFLICT` | Seam | 提供方 id 或某个扩展名已被注册 | 移除重复注册 | +| `LSP_UNAVAILABLE` | Seam | 没有提供方处理该文件扩展名 | 查询受支持的文件,或添加提供方 | +| `LSP_WORKSPACE_REQUIRED` | `lsp` 工具 | 调用没有 Agent Session 工作区 | 从 Agent Session 调用该工具 | +| `LSP_DISPOSED` | 提供方 | 提供方或其服务器实例已被释放 | 在提供方重新挂载后重试 | +| `LSP_UNSUPPORTED_OPERATION` | 提供方 | 服务器不支持该操作或临时打开/关闭同步 | 选择其他操作或服务器 | +| `LSP_MALFORMED_RESPONSE` | 提供方 | 服务器的 `initialize` 或查询结果违反协议 | 报告服务器缺陷 | +| `LSP_FILE_OUTSIDE_WORKSPACE` | stdio 提供方 | 源文件解析到规范工作区之外 | 查询工作区内的文件 | +| `LSP_UNSUPPORTED_ENCODING` | stdio 提供方 | 服务器协商了 UTF-16 以外的位置编码 | 使用支持 UTF-16 的服务器 | +| `LSP_SPAWN_FAILED` | stdio 提供方 | 子进程提供方未能启动或运行服务器;提供方的 `HarnessError` 则保留其自身的 `name` 和 `code` | 修正服务器命令或安装 | +| `LSP_SERVER_EXITED` | stdio 提供方 | 服务器已退出或关闭了 stdin;消息携带保留的 stderr 尾部 | 稍后重试;反复出现时检查 stderr 尾部 | +| `LSP_PROTOCOL_ERROR` | stdio 提供方 | 服务器输出破坏了基础协议分帧或 JSON | 报告服务器缺陷 | +| `LSP_MESSAGE_TOO_LARGE` | stdio 提供方 | 某条服务器消息超过 `maxMessageBytes` | 缩小查询范围或调大 `maxMessageBytes` | +| `LSP_SERVER_ERROR` | stdio 提供方 | 服务器以 JSON-RPC 错误应答;消息携带其数字错误码与文本 | 按服务器消息处理 | +| `LSP_NOT_INITIALIZED` | stdio 提供方 | 握手已成功,但查询时服务器实例没有已协商的能力;这是内部状态不一致,不是服务器的情况 | 报告该缺陷 | + +传输失败(`LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`)后,如果失败的进程干净地完成拆除,stdio 提供方会在新进程上把只读查询重试一次。 diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index 1d00d484cc..369a475ea4 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/README.i18n.yaml @@ -24,8 +24,8 @@ en: 41363baf8fdba020 zh: d1a6193b1aa813ba /deepseek-ai-dsh-lsp-stdio/use-this-package/observable-success-and-failures: - en: 57f3a82c014af107 - zh: 770fc617c1ab0eab + en: 27766a0ecd0f70fa + zh: 532bc93a8ce5e701 /deepseek-ai-dsh-lsp-stdio/use-this-package/security-boundary: en: f1f7fef771bcaa6d zh: f11dcf771a6fecfc diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index ddcfb78292..8f30301f48 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -68,7 +68,7 @@ On the first query for a workspace, the provider launches one server process for ### Observable success and failures -A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. The query fails when the server does not support the operation or the transient open/close synchronization (`LSP_UNSUPPORTED_OPERATION`), when the source is missing, non-regular, non-UTF-8, oversized, or outside the canonical workspace (rejected before the server starts), or when the server returns a malformed payload (`LSP_MALFORMED_RESPONSE`). A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. +A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. Before the server starts, a missing, non-directory, non-regular, non-UTF-8, or oversized workspace or source fails with its `FsError` code, and a source outside the canonical workspace fails with `LSP_FILE_OUTSIDE_WORKSPACE`. The server then fails the query with `LSP_UNSUPPORTED_OPERATION` when it lacks the operation or the transient open/close synchronization, `LSP_UNSUPPORTED_ENCODING` for a non-UTF-16 position encoding, `LSP_MALFORMED_RESPONSE` for a malformed payload, and `LSP_SERVER_ERROR` for a JSON-RPC error response; a failed process or stream reports `LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`; a query that finds the instance without negotiated capabilities after a successful handshake reports `LSP_NOT_INITIALIZED`, which marks a provider defect. The [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) gives each code's meaning and consumer action. A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. ### Security boundary diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index aef549f4bf..f54eda31c1 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" ### 可观察的成功与失败 -成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。当服务器不支持该操作或临时打开/关闭同步(`LSP_UNSUPPORTED_OPERATION`)、源文件缺失、非普通文件、非 UTF-8、过大或位于规范工作区之外(在服务器启动前被拒绝),或服务器返回格式错误的载荷(`LSP_MALFORMED_RESPONSE`)时,查询会失败。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 +成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。在服务器启动前,缺失、非目录、非普通文件、非 UTF-8 或过大的工作区或源文件以其 `FsError` 错误码失败,位于规范工作区之外的源文件以 `LSP_FILE_OUTSIDE_WORKSPACE` 失败。随后,服务器不支持该操作或临时打开/关闭同步时查询以 `LSP_UNSUPPORTED_OPERATION` 失败,非 UTF-16 位置编码以 `LSP_UNSUPPORTED_ENCODING` 失败,格式错误的载荷以 `LSP_MALFORMED_RESPONSE` 失败,JSON-RPC 错误响应以 `LSP_SERVER_ERROR` 失败;进程或流失败时报告 `LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`;握手成功后查询却发现实例没有已协商的能力时报告 `LSP_NOT_INITIALIZED`,表示提供方存在缺陷。[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)给出每个错误码的含义与消费方操作。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 ### 安全边界 diff --git a/packages/lsp/lsp-stdio/src/abort.ts b/packages/lsp/lsp-stdio/src/abort.ts index c6706fc8b8..be86684d5d 100644 --- a/packages/lsp/lsp-stdio/src/abort.ts +++ b/packages/lsp/lsp-stdio/src/abort.ts @@ -8,14 +8,17 @@ import { timeoutOf } from '@deepseek-ai/dsh-timeout' /** * Build an abort Error carrying the signal's reason and preserving timeout classification. * @param signal - the aborted signal whose reason to surface. - * @returns the timeout reason if present, else the Error reason, else a generic aborted Error. + * @returns the timeout reason if present, else the Error reason, else an `AbortError`-named Error + * whose `cause` is the non-Error reason, so tool cancellation classification still applies. */ export function abortError(signal: AbortSignal): Error { const timeout = timeoutOf(signal) if (timeout !== undefined) return timeout const reason: unknown = signal.reason if (reason instanceof Error) return reason - return new Error('LSP query aborted') + const aborted = new Error('LSP query aborted', { cause: reason }) + aborted.name = 'AbortError' + return aborted } /** diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index bdc4b3fb11..e910ac0a52 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -11,6 +11,8 @@ */ import type { Writable } from 'node:stream' +import { LspError } from '@deepseek-ai/dsh-lsp' +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' @@ -39,6 +41,7 @@ export interface ConnectionSpec { } interface Pending { + method: string resolve: (value: unknown) => void reject: (error: Error) => void } @@ -62,7 +65,13 @@ const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => { stdin.write(encodeMessage(message), done) } -/** A live JSON-RPC endpoint bound to one child process. */ +/** + * A live JSON-RPC endpoint bound to one child process. Request and write failures are `LspError`s: + * `LSP_SERVER_EXITED` when the process exits or its stdin fails, `LSP_SPAWN_FAILED` when the + * subprocess provider fails to start or run it (a provider `HarnessError` keeps its own identity), + * `LSP_PROTOCOL_ERROR` or `LSP_MESSAGE_TOO_LARGE` when its stdout breaks framing, and + * `LSP_SERVER_ERROR` when it answers a request with a JSON-RPC error. + */ export class LspConnection { private readonly handle: SubprocessHandle private readonly stdin: Writable @@ -70,6 +79,8 @@ export class LspConnection { private readonly pending = new Map() private nextId = 1 private closeReason: Error | undefined + /** The subprocess provider's launch or run failure; it supersedes the stdin failure it causes. */ + private launchFailure: Error | undefined /** Set once the process has fully exited; the instance awaits it during teardown. */ readonly closed: Promise @@ -110,7 +121,7 @@ export class LspConnection { this.stdin = this.handle.stdin this.closed = new Promise((resolve) => { const close = (): void => { - const reason = this.closeReason ?? new Error(this.exitMessage()) + const reason = this.closeReason ?? new LspError(this.exitMessage(), 'LSP_SERVER_EXITED') // Record the reason so any request issued AFTER close rejects immediately instead of hanging // (a closed process sends no further responses). this.closeReason = reason @@ -120,14 +131,16 @@ export class LspConnection { this.handle.done.then(close, (error: unknown) => { // A spawn-level failure never produces a close event; the rejection is // the fatal cause and the close boundary at once. - this.fail(asError(error)) + const failure = spawnFailure(error) + this.launchFailure = failure + this.fail(failure) close() }) }) // Child stdin can fail while the process itself remains alive (for example, a server closes fd // 0). Treat that as a fatal connection error so pending requests reject immediately instead of // waiting for a process-close event that may never arrive. - this.stdin.on('error', (error) => { this.fail(error) }) + this.stdin.on('error', (error) => { this.fail(this.stdinFailure(error)) }) this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) } @@ -148,7 +161,18 @@ export class LspConnection { * @returns `true` only when this connection produced that exact failure. */ failedWith(error: unknown): boolean { - return this.closeReason === error + return this.closeReason === error || (this.launchFailure !== undefined && this.launchFailure === error) + } + + /** + * Report a transport failure by its authoritative cause once the process has settled. A failed + * launch usually surfaces first as a stdin write failure; after `done` rejects, that failure + * reports the launch failure instead. + * @param error - error caught by the instance after teardown. + * @returns the launch failure for this connection's transport failure, otherwise `error`. + */ + settledFailure(error: unknown): unknown { + return this.launchFailure !== undefined && this.failedWith(error) ? this.launchFailure : error } /** @@ -164,7 +188,7 @@ export class LspConnection { reject(this.closeReason) return } - this.pending.set(id, { resolve, reject }) + this.pending.set(id, { method, resolve, reject }) // `write()` records either synchronous or callback-delivered failures on the connection and // rejects every pending request. This handler only consumes the write promise itself. void this.write({ jsonrpc: '2.0', id, method, params }).catch(() => {}) @@ -266,8 +290,7 @@ export class LspConnection { this.pending.delete(id) const error = frame.error if (error !== null && typeof error === 'object') { - const record = error as Record - pending.reject(new Error(typeof record.message === 'string' ? record.message : 'LSP error response')) + pending.reject(responseError(pending.method, error as Record)) return } pending.resolve(frame.result) @@ -281,8 +304,9 @@ export class LspConnection { resolve() return } - this.fail(error) - reject(error) + const failure = this.stdinFailure(error) + this.fail(failure) + reject(failure) } try { this.writer(this.stdin, message, done) @@ -303,8 +327,20 @@ export class LspConnection { return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}` } + /** + * Convert a stdin failure to `LSP_SERVER_EXITED`. The stream reports one failure through both its + * `error` event and the write callback, so the second report reuses the first conversion and + * {@link failedWith} still recognizes the rejection. + */ + private stdinFailure(error: Error): Error { + const current = this.closeReason + if (current instanceof LspError && current.cause === error) return current + return new LspError(`language server stopped accepting input: ${error.message}`, 'LSP_SERVER_EXITED', { cause: error }) + } + private fail(error: Error): void { - /* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */ + /* v8 ignore next -- the second arm needs a second failure before close, such as a launch failure after a stdin + failure; the subprocess provider does not fix that order, so coverage cannot rely on it. */ if (this.closeReason === undefined) this.closeReason = error this.failAll(error) } @@ -316,6 +352,22 @@ export class LspConnection { } } +/** + * Convert a subprocess `done` rejection to `LSP_SPAWN_FAILED`; a provider `HarnessError` already + * carries the classification and propagates unchanged. + */ +function spawnFailure(error: unknown): Error { + if (error instanceof HarnessError) return error + return new LspError(`language server process failed: ${asError(error).message}`, 'LSP_SPAWN_FAILED', { cause: error }) +} + +/** Convert a JSON-RPC error response, keeping its numeric code and message in `message`. */ +function responseError(method: string, error: Record): LspError { + const code = typeof error.code === 'number' ? ` ${error.code}` : '' + const detail = typeof error.message === 'string' ? error.message : 'no message' + return new LspError(`language server rejected ${method} with JSON-RPC error${code}: ${detail}`, 'LSP_SERVER_ERROR') +} + /** Coerce an unknown thrown value to an `Error`. */ function asError(value: unknown): Error { /* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */ diff --git a/packages/lsp/lsp-stdio/src/framing.ts b/packages/lsp/lsp-stdio/src/framing.ts index 7d7a44dad8..536d354875 100644 --- a/packages/lsp/lsp-stdio/src/framing.ts +++ b/packages/lsp/lsp-stdio/src/framing.ts @@ -5,6 +5,8 @@ * @module @deepseek-ai/dsh-lsp-stdio/framing */ +import { LspError } from '@deepseek-ai/dsh-lsp' + /** The header/body separator in the LSP base protocol. */ const HEADER_SEPARATOR = '\r\n\r\n' @@ -42,7 +44,8 @@ export class MessageDecoder { * Append a chunk and return every message body that is now complete. * @param chunk - raw bytes from the server's stdout. * @returns the parsed JSON bodies, in arrival order (possibly empty). - * @throws Error when a header is malformed or a body exceeds `maxMessageBytes`. + * @throws LspError `LSP_PROTOCOL_ERROR` when a header or body is malformed, or + * `LSP_MESSAGE_TOO_LARGE` when a body exceeds `maxMessageBytes`. */ push(chunk: Buffer): unknown[] { this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]) @@ -60,17 +63,20 @@ export class MessageDecoder { const separator = this.buffer.indexOf(HEADER_SEPARATOR) if (separator < 0) { if (this.buffer.length > MAX_HEADER_BYTES) { - throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`) + throw protocolError(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`) } return { ready: false } } if (separator > MAX_HEADER_BYTES) { - throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`) + throw protocolError(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`) } const headerText = this.buffer.toString('ascii', 0, separator) const contentLength = parseContentLength(headerText) if (contentLength > this.maxMessageBytes) { - throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`) + throw new LspError( + `LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`, + 'LSP_MESSAGE_TOO_LARGE', + ) } const bodyStart = separator + HEADER_SEPARATOR.length const bodyEnd = bodyStart + contentLength @@ -81,7 +87,10 @@ export class MessageDecoder { return { ready: true, message: JSON.parse(body) } } catch (error) { /* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */ - throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`) + throw protocolError( + `LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ) } } } @@ -94,9 +103,14 @@ function parseContentLength(headerText: string): number { if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue const value = Number(line.slice(colon + 1).trim()) if (!Number.isInteger(value) || value < 0) { - throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`) + throw protocolError(`invalid Content-Length header: ${JSON.stringify(line)}`) } return value } - throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`) + throw protocolError(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`) +} + +/** A framing failure: the server's byte stream no longer follows the LSP base protocol. */ +function protocolError(message: string, options?: ErrorOptions): LspError { + return new LspError(message, 'LSP_PROTOCOL_ERROR', options) } diff --git a/packages/lsp/lsp-stdio/src/host.ts b/packages/lsp/lsp-stdio/src/host.ts index ef4b56809d..d28561ea90 100644 --- a/packages/lsp/lsp-stdio/src/host.ts +++ b/packages/lsp/lsp-stdio/src/host.ts @@ -1,7 +1,9 @@ /** Filesystem-seam source access for the generic stdio LSP provider. */ import { Buffer } from 'node:buffer' +import { FsError } from '@deepseek-ai/dsh-fs' import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs' +import { LspError } from '@deepseek-ai/dsh-lsp' import { throwIfAborted } from './abort.ts' /** A canonical workspace in the filesystem/subprocess execution world. */ @@ -28,6 +30,8 @@ export interface HostSource { * @param workspaceRoot - caller-supplied workspace path. * @param signal - optional cancellation around provider operations. * @returns stable identity plus process path and file URI. + * @throws FsError with the provider's code when resolution or `stat` fails, `FS_NOT_FOUND` when the + * workspace is missing, or `FS_NOT_DIRECTORY` when it is not a directory. */ export async function canonicalizeWorkspace( fs: FileSystem, @@ -40,7 +44,7 @@ export async function canonicalizeWorkspace( target = await fs.resolve(workspaceRoot, signal === undefined ? {} : { signal }) } catch (error: unknown) { throwIfAborted(signal) - throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`, { cause: error }) + rethrowWithFsContext(error, `workspace root "${workspaceRoot}" cannot be resolved`) } throwIfAborted(signal) const info = await fs.stat(target, signal).catch((error: unknown) => { @@ -48,8 +52,11 @@ export async function canonicalizeWorkspace( throw error }) throwIfAborted(signal) - if (info?.type !== 'directory') { - throw new Error(`workspace root "${workspaceRoot}" is not a directory`) + if (info === undefined) { + throw new FsError(`workspace root "${workspaceRoot}" does not exist`, 'FS_NOT_FOUND') + } + if (info.type !== 'directory') { + throw new FsError(`workspace root "${workspaceRoot}" is not a directory`, 'FS_NOT_DIRECTORY') } return { target, @@ -68,6 +75,9 @@ export async function canonicalizeWorkspace( * @param maxDocumentBytes - largest complete source accepted by this host. * @param signal - optional cancellation. * @returns canonical file URI and current text. + * @throws FsError with the provider's code when resolution or reading fails, or `FS_TOO_LARGE` when + * the source exceeds `maxDocumentBytes`; LspError `LSP_FILE_OUTSIDE_WORKSPACE` when the source + * resolves outside `workspace`. */ export async function readHostSource( fs: FileSystem, @@ -85,11 +95,11 @@ export async function readHostSource( }) } catch (error: unknown) { throwIfAborted(signal) - throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`, { cause: error }) + rethrowWithFsContext(error, `source "${filePath}" cannot be resolved`) } throwIfAborted(signal) if (!fs.contains(workspace.target, target)) { - throw new Error(`source "${filePath}" resolves outside the workspace`) + throw new LspError(`source "${filePath}" resolves outside the workspace`, 'LSP_FILE_OUTSIDE_WORKSPACE') } const chunks: string[] = [] let bytes = 0 @@ -105,11 +115,12 @@ export async function readHostSource( } } catch (error: unknown) { throwIfAborted(signal) - throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error }) + rethrowWithFsContext(error, `source "${filePath}" could not be read`) } if (bytes > maxDocumentBytes) { - throw new Error( + throw new FsError( `source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit; reading stopped after ${bytes} bytes`, + 'FS_TOO_LARGE', ) } throwIfAborted(signal) @@ -119,6 +130,11 @@ export async function readHostSource( } } -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) +/** + * Prefix a filesystem provider failure with the LSP operand while keeping its `FsError` code; any + * other thrown value is a provider defect and propagates unchanged. + */ +function rethrowWithFsContext(error: unknown, context: string): never { + if (!(error instanceof FsError)) throw error + throw new FsError(`${context}: ${error.message}`, error.code, { cause: error }) } diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index bfe1974e38..1e7fe51591 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -14,6 +14,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp' +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { LspProvider, LspProviderQuery, @@ -116,6 +117,32 @@ function throwTeardownFailures(results: readonly PromiseSettledResult[], m if (failures.length > 1) throw new AggregateError(failures, message) } +/** + * A coded query failure whose instance teardown also failed. It keeps the query failure's `name` + * and `code` so tool results route on the query outcome; `cause` aggregates both failures. + */ +class LspQueryTeardownError extends HarnessError { + constructor(query: HarnessError, teardown: unknown, cause: AggregateError) { + super(`${query.message}; language server teardown also failed: ${messageOf(teardown)}`, query.code, { cause }) + this.name = query.name + } +} + +/** + * Combine a query failure with the teardown failure of its instance. A `HarnessError` query + * failure keeps its identity on the combined error; any other query failure has no identity to keep + * and both surface as an `AggregateError`. + */ +function queryAndTeardownFailure(query: unknown, teardown: unknown): Error { + const both = new AggregateError([query, teardown], 'LSP operation and teardown failed') + return query instanceof HarnessError ? new LspQueryTeardownError(query, teardown, both) : both +} + +/** Read a thrown value's message for a combined failure report. */ +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + /** * Register the configured stdio LSP providers. Resolves every executable at load (after credential * scrubbing) before publishing any provider; each process launches lazily on its first matching @@ -294,10 +321,7 @@ class LocalLspProvider implements LspProvider { } if (teardownOutcome?.status === 'rejected') { if (queryOutcome.status === 'rejected') { - throw new AggregateError( - [queryOutcome.reason, teardownOutcome.reason], - 'LSP operation and teardown failed', - ) + throw queryAndTeardownFailure(queryOutcome.reason, teardownOutcome.reason) } throw teardownOutcome.reason } diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index d86d484571..f72a892fd5 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -18,8 +18,9 @@ import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts' import type { HostSource } from './host.ts' -import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' +import type { WireServerCapabilities } from './protocol.ts' import { + initializeCapabilities, negotiatePositionEncoding, normalizeHover, normalizeLocations, @@ -97,8 +98,9 @@ export class LspInstance { const run = abortable(this.queue, signal) .then(() => this.runQuery(request, source, signal)) .catch(async (error: unknown) => { - if (this.isTransportFailure(error)) await this.awaitTeardownAttempt() - throw error + if (!this.isTransportFailure(error)) throw error + await this.awaitTeardownAttempt() + throw this.connection.settledFailure(error) }) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up @@ -116,8 +118,8 @@ export class LspInstance { workspaceFolders: [{ uri: this.spec.workspaceUri, name: 'workspace' }], capabilities: CLIENT_CAPABILITIES, initializationOptions: this.spec.initializationOptions, - }) as WireInitializeResult - const capabilities = initializeResult.capabilities + }) + const capabilities = initializeCapabilities(initializeResult) // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. negotiatePositionEncoding(capabilities.positionEncoding) this.capabilities = capabilities @@ -141,8 +143,8 @@ export class LspInstance { throw error } const capabilities = this.capabilities - /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */ - if (capabilities === undefined) throw new Error('LSP instance is not initialized') + // `ready` resolves only after capabilities are set; reaching this means instance state is inconsistent. + if (capabilities === undefined) throw new LspError('LSP instance is not initialized', 'LSP_NOT_INITIALIZED') if (!supportsOperation(capabilities, request.operation)) { throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION') } diff --git a/packages/lsp/lsp-stdio/src/protocol.ts b/packages/lsp/lsp-stdio/src/protocol.ts index 96257678ed..e91e45ed5c 100644 --- a/packages/lsp/lsp-stdio/src/protocol.ts +++ b/packages/lsp/lsp-stdio/src/protocol.ts @@ -73,8 +73,3 @@ export interface WireServerCapabilities { readonly implementationProvider?: WireProviderCapability readonly hoverProvider?: WireProviderCapability } - -/** The `initialize` result envelope. */ -export interface WireInitializeResult { - readonly capabilities: WireServerCapabilities -} diff --git a/packages/lsp/lsp-stdio/src/translate.ts b/packages/lsp/lsp-stdio/src/translate.ts index a2de8ed419..8b26fc5d07 100644 --- a/packages/lsp/lsp-stdio/src/translate.ts +++ b/packages/lsp/lsp-stdio/src/translate.ts @@ -92,11 +92,31 @@ function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean { * other than `utf-16` is a protocol error this host does not support. * @param encoding - the server's advertised `positionEncoding`, if any. * @returns the string `'utf-16'`. - * @throws Error for any non-`utf-16` encoding. + * @throws LspError `LSP_UNSUPPORTED_ENCODING` for any non-`utf-16` encoding. */ export function negotiatePositionEncoding(encoding: string | undefined): 'utf-16' { if (encoding === undefined || encoding === 'utf-16') return 'utf-16' - throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`) + throw new LspError( + `server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`, + 'LSP_UNSUPPORTED_ENCODING', + ) +} + +/** + * Read the server capabilities from an untrusted `initialize` result. + * @param result - the raw `initialize` response result. + * @returns the advertised capabilities object. + * @throws LspError `LSP_MALFORMED_RESPONSE` when the result or its `capabilities` is not an object. + */ +export function initializeCapabilities(result: unknown): WireServerCapabilities { + if (result === null || typeof result !== 'object') { + throw malformedResponse('LSP initialize result was not an object') + } + const capabilities = (result as Record).capabilities + if (capabilities === null || typeof capabilities !== 'object') { + throw malformedResponse('LSP initialize result had no capabilities object') + } + return capabilities } /** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */ @@ -141,7 +161,8 @@ function isProtocolCoordinate(value: unknown): value is number { * locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`. * @param payload - the raw `textDocument/definition|references|implementation` result. * @returns the normalized locations (empty for `null`/`[]`). - * @throws Error when an element is neither a `Location` nor a `LocationLink`. + * @throws LspError `LSP_MALFORMED_RESPONSE` when the result is missing or an element is neither a + * `Location` nor a `LocationLink`. */ export function normalizeLocations(payload: unknown): LspLocation[] { if (payload === null) return [] @@ -178,7 +199,8 @@ function renderMarkedString(value: WireMarkedString): string { * joins its rendered parts with one blank line. The model-facing tool owns the complete result cap. * @param payload - the raw `textDocument/hover` result. * @returns the normalized hover, or `null` when there is no content. - * @throws Error when the payload is a non-null, non-object, or structurally invalid hover. + * @throws LspError `LSP_MALFORMED_RESPONSE` when the payload is a non-null, non-object, or + * structurally invalid hover. */ export function normalizeHover(payload: unknown): LspHover | null { if (payload === null) return null diff --git a/packages/lsp/lsp-stdio/tests/connection.spec.ts b/packages/lsp/lsp-stdio/tests/connection.spec.ts index 37a9ef84e6..9428db7ad7 100644 --- a/packages/lsp/lsp-stdio/tests/connection.spec.ts +++ b/packages/lsp/lsp-stdio/tests/connection.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' +import { HarnessError } from '@deepseek-ai/dsh-llm' import { LspConnection } from '@deepseek-ai/dsh-lsp-stdio' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' @@ -62,7 +63,11 @@ describe('LspConnection', () => { it('rejects a request when the server replies with an error', async () => { const conn = connect({ LSP_FAKE_ERROR: '1' }) await conn.request('initialize', { capabilities: {} }) - await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) + await expect(conn.request('textDocument/hover', {})).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_ERROR', + message: 'language server rejected textDocument/hover with JSON-RPC error -32000: server refused the request', + }) }) it('treats terminating an already-closed child as a teardown race', async () => { @@ -124,7 +129,11 @@ describe('LspConnection', () => { await conn.request('initialize', { capabilities: {} }) conn.terminate() await conn.closed - await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/) + await expect(conn.request('textDocument/hover', {})).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: expect.stringMatching(/^language server exited/) as string, + }) }) it('cancel is a no-op-safe write after close', async () => { @@ -171,13 +180,52 @@ describe('LspConnection edge behavior', () => { configuration: null, }, spawnSubprocess, () => Promise.resolve(null)) open.push(conn) - await expect(conn.request('initialize', {})).rejects.toThrow() + // The failed launch usually surfaces first as a stdin write failure; once the process settles, + // the connection reports the provider's launch failure for it. + const failure: unknown = await conn.request('initialize', {}).catch((error: unknown) => error) + expect(failure).toMatchObject({ name: 'LspError', code: expect.stringMatching(/^LSP_(SERVER_EXITED|SPAWN_FAILED)$/) as string }) + await conn.closed + expect(conn.failedWith(failure)).toBe(true) + expect(conn.settledFailure(failure)).toMatchObject({ + name: 'LspError', + code: 'LSP_SPAWN_FAILED', + message: expect.stringMatching(/^language server process failed: .*ENOENT/) as string, + cause: expect.any(Error) as Error, + }) + const unrelated = new Error('unrelated') + expect(conn.settledFailure(unrelated)).toBe(unrelated) + }) + + it('keeps a subprocess provider HarnessError as the spawn failure', async () => { + const providerFailure = new HarnessError('sandbox refused the launch', 'SANDBOX_DENIED') + const conn = new LspConnection({ + command: process.execPath, + args: ['-e', 'setInterval(()=>{}, 1000)'], + cwd: process.cwd(), + env: scrubbedParentEnv(), + maxMessageBytes: 1000, + maxStderrBytes: 1000, + killGraceMs: 3_000, + configuration: null, + }, (spec) => { + const handle = spawnSubprocess(spec) + return Object.assign(Object.create(handle) as typeof handle, { + done: handle.done.then(() => { throw providerFailure }), + }) + }, () => Promise.resolve(null)) + open.push(conn) + const failure: unknown = await Promise.all([ + conn.request('initialize', {}).catch((error: unknown) => error), + Promise.resolve().then(() => { conn.terminate() }), + ]).then(([error]) => error) + await conn.closed + expect(conn.settledFailure(failure)).toBe(providerFailure) }) it('kills the process and fails pending requests on a framing error', async () => { // Emit an invalid Content-Length header, corrupting the stream irrecoverably. const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)') - await expect(conn.request('initialize', {})).rejects.toThrow() + await expect(conn.request('initialize', {})).rejects.toMatchObject({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR' }) }) it('ignores a framed non-object message', async () => { @@ -215,18 +263,25 @@ describe('LspConnection edge behavior', () => { expect(Buffer.byteLength(conn.stderrTail)).toBe(4) }) - it('rejects with a fallback message when the error response has no message string', async () => { + it.each([ + ['no message string', '{code:-1}', 'language server rejected initialize with JSON-RPC error -1: no message'], + ['no numeric code', '{message:"refused"}', 'language server rejected initialize with JSON-RPC error: refused'], + ])('rejects with LSP_SERVER_ERROR when the error response has %s', async (_label, error, message) => { const script = 'let b=Buffer.alloc(0);' + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' - + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});' + + `process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:${error}})));});` const conn = connectScript(script) - await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/) + await expect(conn.request('initialize', {})).rejects.toMatchObject({ name: 'LspError', code: 'LSP_SERVER_ERROR', message }) }) it('rejects a pending request when the process exits mid-flight', async () => { // Never responds, then exits shortly: the pending request must reject on close. const conn = connectScript('setTimeout(()=>process.exit(0), 100)') - await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) + await expect(conn.request('initialize', {})).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: 'language server exited', + }) }) it('rejects a pending request when child stdin fails but the process stays alive', async () => { @@ -235,7 +290,27 @@ describe('LspConnection edge behavior', () => { queueMicrotask(() => { done(failure) }) } const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer) - await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/) + await expect(conn.request('initialize', {})).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: 'language server stopped accepting input: fixture stdin failure', + cause: failure, + }) + }) + + it('reports one stdin failure from the error event and the write callback as one transport failure', async () => { + const failure = new Error('fixture stdin failure') + const writer: ConnectionWriter = (stdin, _message, done) => { + queueMicrotask(() => { + stdin.emit('error', failure) + done(failure) + }) + } + const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer) + const rejection: unknown = await conn.request('initialize', {}).catch((error: unknown) => error) + expect(rejection).toMatchObject({ name: 'LspError', code: 'LSP_SERVER_EXITED', cause: failure }) + expect(conn.failedWith(rejection)).toBe(true) + await expect(conn.notify('initialized', {})).rejects.toBe(rejection) }) it('ignores a frame that is neither a valid request nor a numeric-id response', async () => { diff --git a/packages/lsp/lsp-stdio/tests/framing.spec.ts b/packages/lsp/lsp-stdio/tests/framing.spec.ts index 51763db0d4..3d82110d48 100644 --- a/packages/lsp/lsp-stdio/tests/framing.spec.ts +++ b/packages/lsp/lsp-stdio/tests/framing.spec.ts @@ -50,33 +50,33 @@ describe('MessageDecoder', () => { it('rejects a body over the size limit', () => { const decoder = new MessageDecoder(4) - expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/) + expect(() => decoder.push(frame('{"big":true}'))).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_MESSAGE_TOO_LARGE', message: expect.stringMatching(/exceeds the 4-byte limit/) as string })) }) it('rejects a missing Content-Length header', () => { const decoder = new MessageDecoder(1_000) - expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/) + expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/missing Content-Length/) as string })) }) it('rejects a non-numeric Content-Length', () => { const decoder = new MessageDecoder(1_000) - expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/) + expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/invalid Content-Length/) as string })) }) it('rejects a header block that never terminates', () => { const decoder = new MessageDecoder(1_000) const huge = Buffer.alloc((1 << 16) + 1, 0x41) - expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/) + expect(() => decoder.push(huge)).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/exceeded .* bytes without a terminator/) as string })) }) it('rejects an oversized header block that includes its terminator', () => { const decoder = new MessageDecoder(1_000) const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii') - expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/) + expect(() => decoder.push(huge)).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/header exceeded .* bytes/) as string })) }) it('rejects a non-JSON body', () => { const decoder = new MessageDecoder(1_000) - expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/) + expect(() => decoder.push(frame('not json'))).toThrow(expect.objectContaining({ name: 'LspError', code: 'LSP_PROTOCOL_ERROR', message: expect.stringMatching(/not valid JSON/) as string })) }) }) diff --git a/packages/lsp/lsp-stdio/tests/host.spec.ts b/packages/lsp/lsp-stdio/tests/host.spec.ts index d75e025af1..49400dbf07 100644 --- a/packages/lsp/lsp-stdio/tests/host.spec.ts +++ b/packages/lsp/lsp-stdio/tests/host.spec.ts @@ -7,6 +7,7 @@ import { execFile } from 'node:child_process' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' +import { FsError } from '@deepseek-ai/dsh-fs' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { deadline } from '@deepseek-ai/dsh-timeout' import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-stdio' @@ -54,19 +55,38 @@ describe('canonicalizeWorkspace', () => { }) it('rejects a missing workspace', async () => { - await expect(canonicalizeWorkspace(fs, join(root, 'nope'))).rejects.toThrow(/not a directory/) + const missing = join(root, 'nope') + await expect(canonicalizeWorkspace(fs, missing)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_FOUND', + message: `workspace root "${missing}" does not exist`, + }) }) - it('wraps a provider failure while resolving the workspace', async () => { + it('adds the workspace operand to a filesystem failure while keeping its code', async () => { + const providerFailure = new FsError('permission denied', 'FS_PERMISSION_DENIED') + fs.resolve = async () => { throw providerFailure } + await expect(canonicalizeWorkspace(fs, ws)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_PERMISSION_DENIED', + message: `workspace root "${ws}" cannot be resolved: permission denied`, + cause: providerFailure, + }) + }) + + it('propagates a non-filesystem resolve failure unchanged', async () => { fs.resolve = async () => { throw 'raw workspace resolve failure' } - await expect(canonicalizeWorkspace(fs, ws)) - .rejects.toThrow(`workspace root "${ws}" cannot be resolved: raw workspace resolve failure`) + await expect(canonicalizeWorkspace(fs, ws)).rejects.toBe('raw workspace resolve failure') }) it('rejects a non-directory workspace', async () => { const file = join(root, 'file.txt') await writeFile(file, 'x') - await expect(canonicalizeWorkspace(fs, file)).rejects.toThrow(/not a directory/) + await expect(canonicalizeWorkspace(fs, file)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_DIRECTORY', + message: `workspace root "${file}" is not a directory`, + }) }) it('normalizes workspace metadata cancellation and preserves other provider failures', async () => { @@ -111,29 +131,56 @@ describe('readHostSource', () => { const outside = join(root, 'outside.ts') await writeFile(outside, 'secret') await symlink(outside, join(ws, 'escape.ts')) - await expect(readSource('escape.ts')).rejects.toThrow(/outside the workspace/) + await expect(readSource('escape.ts')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_FILE_OUTSIDE_WORKSPACE', + message: 'source "escape.ts" resolves outside the workspace', + }) }) it('rejects an absolute source outside the workspace', async () => { const outside = join(root, 'out.ts') await writeFile(outside, 'x') - await expect(readSource(outside)).rejects.toThrow(/outside the workspace/) + await expect(readSource(outside)).rejects.toMatchObject({ name: 'LspError', code: 'LSP_FILE_OUTSIDE_WORKSPACE' }) }) it('rejects a missing source', async () => { - await expect(readSource('nope.ts')).rejects.toThrow(/not found/) + await expect(readSource('nope.ts')).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_FOUND', + message: expect.stringMatching(/^source "nope\.ts" could not be read: .*not found/) as string, + }) }) - it('wraps a provider failure while resolving the source', async () => { + it('adds the source operand to a resolve failure while keeping its code', async () => { const canonical = await workspace() + const providerFailure = new FsError('a parent path segment is not a directory', 'FS_NOT_FOUND') + fs.resolve = async () => { throw providerFailure } + await expect(readHostSource(fs, 'broken.ts', canonical, BIG)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_FOUND', + message: 'source "broken.ts" cannot be resolved: a parent path segment is not a directory', + cause: providerFailure, + }) fs.resolve = async () => { throw 'raw resolve failure' } - await expect(readHostSource(fs, 'broken.ts', canonical, BIG)) - .rejects.toThrow('source "broken.ts" cannot be resolved: raw resolve failure') + await expect(readHostSource(fs, 'broken.ts', canonical, BIG)).rejects.toBe('raw resolve failure') + }) + + it('propagates a non-filesystem read failure unchanged', async () => { + await writeFile(join(ws, 'a.ts'), 'x') + const canonical = await workspace() + const providerFailure = new Error('stream defect') + fs.streamText = async () => { throw providerFailure } + await expect(readHostSource(fs, 'a.ts', canonical, BIG)).rejects.toBe(providerFailure) }) it('rejects a non-regular source (directory)', async () => { await mkdir(join(ws, 'dir')) - await expect(readSource('dir')).rejects.toThrow(/not a regular file/) + await expect(readSource('dir')).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_REGULAR_FILE', + message: expect.stringMatching(/^source "dir" could not be read: .*not a regular file/) as string, + }) }) // Windows has no filesystem FIFO; the directory case above pins non-regular rejection there. @@ -159,6 +206,8 @@ describe('readHostSource', () => { it('rejects an oversized source and reports the observed lower bound', async () => { await writeFile(join(ws, 'big.ts'), 'x'.repeat(100)) await expect(readSource('big.ts', 10)).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_TOO_LARGE', message: 'source "big.ts" exceeds the 10-byte limit; reading stopped after 100 bytes', }) }) @@ -171,7 +220,11 @@ describe('readHostSource', () => { it('rejects a non-UTF-8 source', async () => { await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00])) - await expect(readSource('bin.ts')).rejects.toThrow(/invalid UTF-8|binary file/) + await expect(readSource('bin.ts')).rejects.toMatchObject({ + name: 'FsError', + code: 'FS_NOT_TEXT', + message: expect.stringMatching(/invalid UTF-8|binary file/) as string, + }) }) it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => { diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index bcf2669fd4..10e0a783b1 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -255,7 +255,7 @@ describe('LspInstance query and abort', () => { shutdownTimeoutMs: 100, killGraceMs: 100, }, failingWriter('textDocument/didOpen')) - await expect(run(instance, 'goToDefinition')).rejects.toThrow() + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ name: 'LspError', code: 'LSP_SERVER_EXITED' }) expect(instance.dead).toBe(true) }) @@ -264,13 +264,32 @@ describe('LspInstance query and abort', () => { shutdownTimeoutMs: 100, killGraceMs: 100, }, failingWriter('textDocument/definition')) - await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: 'language server stopped accepting input: fixture textDocument/definition failure', + }) expect(instance.dead).toBe(true) }) it('rejects when the server lacks the operation capability', async () => { const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_OPERATION', + message: 'server does not support goToDefinition', + }) + }) + + it('reports inconsistent instance state as LSP_NOT_INITIALIZED', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' }) + Reflect.set(instance, 'capabilities', undefined) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_NOT_INITIALIZED', + message: 'LSP instance is not initialized', + }) }) it('propagates a server error response even when a signal is supplied (not an abort)', async () => { @@ -278,7 +297,11 @@ describe('LspInstance query and abort', () => { // without treating it as an abort. const instance = makeInstance({ LSP_FAKE_ERROR: '1' }) const controller = new AbortController() - await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/) + await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_ERROR', + message: 'language server rejected textDocument/definition with JSON-RPC error -32000: server refused the request', + }) }) it('keeps a settled result but awaits teardown when didClose cannot be written', async () => { @@ -293,6 +316,32 @@ describe('LspInstance query and abort', () => { expect(instance.dead).toBe(true) }) + it('reports the launch failure when the server executable cannot be spawned', async () => { + const instance = makeInstance({}, { command: join(root, 'missing-server') }) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SPAWN_FAILED', + message: expect.stringMatching(/^language server process failed: .*ENOENT/) as string, + }) + expect(instance.dead).toBe(true) + }) + + it.each([ + ['a non-object result', 'null', 'LSP initialize result was not an object'], + ['no capabilities object', '{}', 'LSP initialize result had no capabilities object'], + ])('rejects an initialize result with %s', async (_label, result, message) => { + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);' + + `if(body.method==="initialize")process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:${result}})));});` + const instance = makeInstance({}, { args: ['-e', script], shutdownTimeoutMs: 100, killGraceMs: 100 }) + await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_MALFORMED_RESPONSE', + message, + }) + }) + }) describe('LspInstance disposal', () => { diff --git a/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts b/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts index 551d1ffe05..62961d01f1 100644 --- a/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts @@ -153,7 +153,11 @@ describe('lsp-stdio end to end over a fake server', () => { LSP_FAKE_DEF: 'null', LSP_FAKE_EXIT_MARKER: marker, }) - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_ENCODING', + message: expect.stringMatching(/unsupported position encoding/) as string, + }) expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n') await ctx.fiber.dispose() }) @@ -168,8 +172,41 @@ describe('lsp-stdio end to end over a fake server', () => { await ctx.fiber.dispose() }) - it('preserves a query failure with final disposal failure and evicts the instance', async () => { - const teardownFailure = new Error('managed range observation failed') + it('combines a non-coded query failure with a final disposal failure', async () => { + const teardownFailure = 'managed range observation failed' + const controller = new AbortController() + const cancelled = new Error('caller cancelled') + const ctx = await mount( + { LSP_FAKE_HANG: '1' }, + { shutdownTimeoutMs: 100, killGraceMs: 100 }, + undefined, + (mounted) => { + const spawn = mounted.subprocess.spawn.bind(mounted.subprocess) + vi.spyOn(mounted.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + const waitForExit = handle.waitForExit.bind(handle) + vi.spyOn(handle, 'waitForExit').mockImplementation(async (signal) => { + await waitForExit(signal) + throw teardownFailure + }) + setTimeout(() => { controller.abort(cancelled) }, 0) + return handle + }) + }, + ) + const failure = await ctx.lsp.query(query('goToDefinition'), controller.signal).then( + () => undefined, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toEqual([cancelled, teardownFailure]) + await ctx.fiber.dispose() + }) + + it.each([ + ['an Error', new Error('managed range observation failed')], + ['a non-Error value', 'managed range observation failed'], + ])('preserves a query failure with final disposal failure (%s) and evicts the instance', async (_label, teardownFailure) => { let provider: LspProvider | undefined let firstSpawn = true let restoreFirstWait: (() => void) | undefined @@ -198,13 +235,20 @@ describe('lsp-stdio end to end over a fake server', () => { () => undefined, (error: unknown) => error, ) - expect(failure).toBeInstanceOf(AggregateError) - const errors = (failure as AggregateError).errors as unknown[] + expect(failure).toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_ENCODING', + message: expect.stringMatching( + /unsupported position encoding .*; language server teardown also failed: managed range observation failed$/, + ) as string, + }) + const cause = (failure as Error).cause + expect(cause).toBeInstanceOf(AggregateError) + const errors = (cause as AggregateError).errors as unknown[] expect(errors).toHaveLength(2) - expect(errors[0]).toBeInstanceOf(Error) - expect((errors[0] as Error).message).toContain('unsupported position encoding') + expect(errors[0]).toMatchObject({ name: 'LspError', code: 'LSP_UNSUPPORTED_ENCODING' }) expect(errors[1]).toBe(teardownFailure) - expect((failure as AggregateError).message).toBe('LSP operation and teardown failed') + expect((cause as AggregateError).message).toBe('LSP operation and teardown failed') restoreFirstWait?.() if (provider === undefined) throw new Error('expected lsp-stdio to register a provider') const instances = (provider as unknown as { readonly instances: ReadonlyMap }).instances @@ -262,7 +306,11 @@ describe('lsp-stdio end to end over a fake server', () => { it('rejects a server without transient-open sync (None)', async () => { const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_OPERATION', + message: expect.stringMatching(/transient textDocument\/didOpen/) as string, + }) await ctx.fiber.dispose() }) @@ -274,7 +322,11 @@ describe('lsp-stdio end to end over a fake server', () => { it('fails a query for an unsupported operation', async () => { const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/) + await expect(ctx.lsp.query(query('hover'))).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_OPERATION', + message: 'server does not support hover', + }) await ctx.fiber.dispose() }) @@ -282,7 +334,10 @@ describe('lsp-stdio end to end over a fake server', () => { const outside = join(root, 'out.ts') await writeFile(outside, 'x') const ctx = await mount({ LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/) + await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_FILE_OUTSIDE_WORKSPACE', + }) await ctx.fiber.dispose() }) @@ -363,6 +418,18 @@ describe('lsp-stdio end to end over a fake server', () => { await ctx.fiber.dispose() }) + it('names a non-Error abort reason as an AbortError', async () => { + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + controller.abort('caller stopped') + await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toMatchObject({ + name: 'AbortError', + message: 'LSP query aborted', + cause: 'caller stopped', + }) + await ctx.fiber.dispose() + }) + it('surfaces the server stderr tail in the exit error', async () => { // A server that writes to stderr then exits without answering: the query rejection carries the // retained stderr tail so the failure is diagnosable. @@ -370,7 +437,11 @@ describe('lsp-stdio end to end over a fake server', () => { command: process.execPath, args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'], }) - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ + name: 'LspError', + code: 'LSP_SERVER_EXITED', + message: expect.stringMatching(/^language server exited; stderr: FATAL: boom/) as string, + }) await ctx.fiber.dispose() }) @@ -383,9 +454,9 @@ describe('lsp-stdio end to end over a fake server', () => { it('fails the active query when the server crashes on open, and replaces it next query', async () => { const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ name: 'LspError', code: 'LSP_SERVER_EXITED' }) // A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang). - await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toMatchObject({ name: 'LspError', code: 'LSP_SERVER_EXITED' }) await ctx.fiber.dispose() }) diff --git a/packages/lsp/lsp-stdio/tests/translate.spec.ts b/packages/lsp/lsp-stdio/tests/translate.spec.ts index e38a9d140c..dc14808ac2 100644 --- a/packages/lsp/lsp-stdio/tests/translate.spec.ts +++ b/packages/lsp/lsp-stdio/tests/translate.spec.ts @@ -61,7 +61,11 @@ describe('negotiatePositionEncoding', () => { }) it('rejects any other encoding', () => { - expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/) + expect(() => negotiatePositionEncoding('utf-8')).toThrow(expect.objectContaining({ + name: 'LspError', + code: 'LSP_UNSUPPORTED_ENCODING', + message: expect.stringMatching(/unsupported position encoding "utf-8"/) as string, + })) }) }) diff --git a/packages/lsp/lsp/src/index.ts b/packages/lsp/lsp/src/index.ts index 05e2cd3643..1c7028552c 100644 --- a/packages/lsp/lsp/src/index.ts +++ b/packages/lsp/lsp/src/index.ts @@ -42,12 +42,23 @@ declare module '@deepseek-ai/cordis' { } /** - * Structured LSP failure. Extends {@link HarnessError} with a stable `code` - * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, - * `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) that callers route on instead of - * parsing `message`. + * Structured LSP failure. Extends {@link HarnessError} with a stable `code` that callers route on + * instead of parsing `message`. The seam raises `LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, and + * `LSP_UNAVAILABLE`; providers raise `LSP_DISPOSED`, `LSP_UNSUPPORTED_OPERATION`, and + * `LSP_MALFORMED_RESPONSE`. Providers and consumers add their own documented codes, so `code` + * stays open and consumers tolerate codes they do not know. */ -export class LspError extends HarnessError {} +export class LspError extends HarnessError { + /** + * @param message - actionable description of the failure. + * @param code - stable failure code. + * @param options - the underlying failure, when one exists. + */ + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'LspError' + } +} /** * Extract a file's final extension as a normalized, lowercase, leading-dot key (e.g. `Foo.TS` → diff --git a/packages/lsp/tool-lsp/README.i18n.yaml b/packages/lsp/tool-lsp/README.i18n.yaml index 15406bd1ed..e8f31cabd5 100644 --- a/packages/lsp/tool-lsp/README.i18n.yaml +++ b/packages/lsp/tool-lsp/README.i18n.yaml @@ -27,8 +27,8 @@ en: c6ca5869a35edefa zh: 65378aff80dfc93c /deepseek-ai-dsh-tool-lsp/use-this-package/failures-and-recovery: - en: b1f8b3bb52434cc6 - zh: a8c9643a754c4768 + en: 93aff7952acc564a + zh: f27835dfeeb726b7 /deepseek-ai-dsh-tool-lsp/understand-the-implementation: en: 6574e484bce0e60b zh: 75688d1f9dde8f48 diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index baa4804ace..72145adbc3 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -49,7 +49,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Failures and recovery -The tool requires an Agent Session; a non-agent call fails with `LSP_WORKSPACE_REQUIRED` before any query. The working-directory service supplies and validates its current root. When no provider handles the file's extension, the query fails with `LSP_UNAVAILABLE`; malformed provider payloads remain structured `LSP_MALFORMED_RESPONSE` errors. These surface to the model as error tool results it can read and route on. +An unknown operation, a blank `file_path`, or a coordinate that is not a positive integer fails with `ToolArgsError` `INVALID_ARGS` before any query. The tool requires an Agent Session; a non-agent call fails with `LSP_WORKSPACE_REQUIRED` before any query. The working-directory service supplies and validates its current root. When no provider handles the file's extension, the query fails with `LSP_UNAVAILABLE`; malformed provider payloads remain structured `LSP_MALFORMED_RESPONSE` errors. These and every provider failure surface to the model as error tool results that keep the failure's `name` and `code`; the [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) lists the codes. ----- diff --git a/packages/lsp/tool-lsp/README.zh.md b/packages/lsp/tool-lsp/README.zh.md index db825eb415..15eab32136 100644 --- a/packages/lsp/tool-lsp/README.zh.md +++ b/packages/lsp/tool-lsp/README.zh.md @@ -49,7 +49,7 @@ kind: "package-reference" ### 失败与恢复 -该工具要求 Agent Session;非 Agent 调用会在任何查询前以 `LSP_WORKSPACE_REQUIRED` 失败。工作目录服务提供并校验当前根目录。当没有提供方处理该文件扩展名时,查询以 `LSP_UNAVAILABLE` 失败;格式错误的提供方载荷仍保持为结构化 `LSP_MALFORMED_RESPONSE` 错误。这些会呈现为模型可读、可路由的错误工具结果。 +未知操作、空白 `file_path` 或不是正整数的坐标会在任何查询前以 `ToolArgsError` `INVALID_ARGS` 失败。该工具要求 Agent Session;非 Agent 调用会在任何查询前以 `LSP_WORKSPACE_REQUIRED` 失败。工作目录服务提供并校验当前根目录。当没有提供方处理该文件扩展名时,查询以 `LSP_UNAVAILABLE` 失败;格式错误的提供方载荷仍保持为结构化 `LSP_MALFORMED_RESPONSE` 错误。这些失败以及所有提供方失败都会呈现为保留失败 `name` 与 `code` 的错误工具结果;[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)列出了这些错误码。 ----- diff --git a/packages/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts index ad77b2a6e2..59fd8b273b 100644 --- a/packages/lsp/tool-lsp/src/render.ts +++ b/packages/lsp/tool-lsp/src/render.ts @@ -6,6 +6,7 @@ * @module @deepseek-ai/dsh-tool-lsp/render */ +import { ToolArgsError } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp' import { posix, win32 } from 'node:path' @@ -41,13 +42,14 @@ export interface LspToolArgs { * positive one-based integers converted to the seam's zero-based position. * @param args - the schema-validated raw arguments. * @returns the validated input with a zero-based position. - * @throws Error when the operation is unknown or a coordinate is not a positive integer. + * @throws ToolArgsError (`INVALID_ARGS`) when the operation is unknown, `file_path` is blank, or a + * coordinate is not a positive integer. */ export function parseLspArgs(args: LspToolArgs): LspToolInput { if (!isOperation(args.operation)) { - throw new Error(`operation must be one of ${LSP_OPERATIONS.join(', ')}`) + throw new ToolArgsError([`operation must be one of ${LSP_OPERATIONS.join(', ')}`]) } - if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.file_path.trim().length === 0) throw new ToolArgsError(['file_path must be a non-empty string']) const line = oneBased(args.line, 'line') const character = oneBased(args.character, 'character') return { @@ -66,7 +68,7 @@ function isOperation(value: string): value is LspOperation { /** Validate a one-based coordinate is a positive integer. */ function oneBased(value: number, name: string): number { if (!Number.isInteger(value) || value < 1) { - throw new Error(`${name} must be a positive integer (one-based)`) + throw new ToolArgsError([`${name} must be a positive integer (one-based)`]) } return value } diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index a0ea788eff..fdde3f79ec 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -102,4 +102,17 @@ describe('tool-lsp integration', () => { expect(result.error?.info?.code).toBe('TOOL_TIMEOUT') await ctx.fiber.dispose() }, 30_000) + + it('keeps provider failure names and codes on tool results', async () => { + const outside = join(root, 'outside.ts') + await writeFile(outside, 'x') + const ctx = await mount(false) + const missing = await call(ctx, { operation: 'goToDefinition', file_path: 'missing.ts', line: 1, character: 1 }) + expect(missing.isError).toBe(true) + expect(missing.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_FOUND' }) + const escaped = await call(ctx, { operation: 'goToDefinition', file_path: outside, line: 1, character: 1 }) + expect(escaped.isError).toBe(true) + expect(escaped.error?.info).toEqual({ name: 'LspError', code: 'LSP_FILE_OUTSIDE_WORKSPACE' }) + await ctx.fiber.dispose() + }, 30_000) }) diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts index 51fb6a5083..44a05f40b2 100644 --- a/packages/lsp/tool-lsp/tests/render.spec.ts +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -31,18 +31,18 @@ describe('parseLspArgs', () => { it('rejects an unknown operation', () => { expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 })) - .toThrow(/operation must be one of/) + .toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: expect.stringMatching(/operation must be one of/) as string })) }) it('rejects a blank file_path', () => { expect(() => parseLspArgs({ operation: 'hover', file_path: ' ', line: 1, character: 1 })) - .toThrow(/file_path/) + .toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: 'invalid arguments: file_path must be a non-empty string' })) }) it('rejects non-positive or non-integer coordinates', () => { - expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/) - expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/) - expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: expect.stringMatching(/line must be a positive integer/) as string })) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: expect.stringMatching(/character must be a positive integer/) as string })) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(expect.objectContaining({ name: 'ToolArgsError', code: 'INVALID_ARGS', message: expect.stringMatching(/line must be a positive integer/) as string })) }) }) diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 4947c40379..5e52ba20f6 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -7,7 +7,7 @@ import { pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import Lsp, { LspError, LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -196,21 +196,44 @@ describe('tool-lsp execution', () => { const { ctx } = await mount(stubProvider(() => okLocations)) const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null) expect(result.isError).toBe(true) - expect(result.error?.info?.code).toBe('LSP_WORKSPACE_REQUIRED') + expect(result.error?.info).toEqual({ name: 'LspError', code: 'LSP_WORKSPACE_REQUIRED' }) }) it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => { const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' })) const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) expect(result.isError).toBe(true) - expect(result.error?.info?.code).toBe('LSP_UNAVAILABLE') + expect(result.error?.info).toEqual({ name: 'LspError', code: 'LSP_UNAVAILABLE' }) }) it('returns a structured INVALID_ARGS on a bad operation', async () => { const { ctx } = await mount(stubProvider(() => okLocations)) const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) expect(result.isError).toBe(true) - expect(result.error?.info?.code).toBe('INVALID_ARGS') + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + }) + + it.each([ + ['a blank file_path', { file_path: ' ', line: 1, character: 1 }, 'file_path must be a non-empty string'], + ['a zero line', { file_path: 'a.ts', line: 0, character: 1 }, 'line must be a positive integer (one-based)'], + ['a fractional character', { file_path: 'a.ts', line: 1, character: 1.5 }, 'character must be a positive integer (one-based)'], + ])('returns a structured INVALID_ARGS for %s', async (_label, args, violation) => { + const provider = stubProvider(() => okLocations) + const { ctx } = await mount(provider) + const result = await call(ctx, { operation: 'hover', ...args }, workspaceRoot) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + expect(result.error?.message).toContain(violation) + expect(provider.seen).toHaveLength(0) + }) + + it('keeps a provider failure\'s name and code on the tool result', async () => { + const { ctx } = await mount(stubProvider(() => { + throw new LspError('language server exited', 'LSP_SERVER_EXITED') + })) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'LspError', code: 'LSP_SERVER_EXITED' }) }) it('forwards exec.signal to the seam query', async () => { From e9ed6104d9a1b8908f7e85b6de6e5a3b378232e4 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:37:56 +0800 Subject: [PATCH 08/81] docs: regenerate config catalog and event graph source lines --- docs/config-catalog.md | 4 ++-- docs/config-catalog.zh.md | 4 ++-- docs/event-producer-consumer.md | 8 ++++---- docs/event-producer-consumer.zh.md | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7edf100e3d..527e193db9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2341,7 +2341,7 @@ export type Config = Readonly> ## `@deepseek-ai/dsh-lsp-stdio` - `inject`: `fs` · `lsp` · `subprocess` -- `source`: [`packages/lsp/lsp-stdio/src/index.ts:82`](../packages/lsp/lsp-stdio/src/index.ts) +- `source`: [`packages/lsp/lsp-stdio/src/index.ts:83`](../packages/lsp/lsp-stdio/src/index.ts) ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -4155,7 +4155,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-fs` - `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory` -- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts) +- `source`: [`packages/fs/tool-fs/src/index.ts:29`](../packages/fs/tool-fs/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c9ed22e1d4..76218f3d03 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2343,7 +2343,7 @@ export type Config = Readonly> ## `@deepseek-ai/dsh-lsp-stdio` - `inject`: `fs` · `lsp` · `subprocess` -- `source`: [`packages/lsp/lsp-stdio/src/index.ts:82`](../packages/lsp/lsp-stdio/src/index.ts) +- `source`: [`packages/lsp/lsp-stdio/src/index.ts:83`](../packages/lsp/lsp-stdio/src/index.ts) ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -4157,7 +4157,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-fs` - `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory` -- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts) +- `source`: [`packages/fs/tool-fs/src/index.ts:29`](../packages/fs/tool-fs/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d6514e9585..5d1f5d9790 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -48,10 +48,10 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | -| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:83`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:75`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:93`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes`, `server` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 38d90599f9..7c9a02987b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -50,10 +50,10 @@ | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | -| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:83`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:75`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:93`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes`, `server` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | From eee5c94a4170f861157c40338ed2d58d06792830 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 21:59:35 +0800 Subject: [PATCH 09/81] feat(attachment): make AttachmentError a HarnessError AttachmentError now extends HarnessError from the dependency-free dsh-error package, keeping its name and every ATTACHMENT_* and image admission code, so tool results record its name and code and adapter failures report the attachment code instead of UNKNOWN. An upgrade guide records the persisted code change. read_image classifies AttachmentError before the HarnessError pass-through so its ImageInputError codes stay unchanged. HarnessError imports this branch added in dsh-tool-fs and dsh-lsp-stdio now come from dsh-error, which both packages declare. --- docs/module-graph.md | 9 +++++--- docs/module-graph.zh.md | 9 +++++--- .../attachment-error-codes/guide.i18n.yaml | 16 +++++++++++++ .../attachment-error-codes/guide.md | 23 +++++++++++++++++++ .../attachment-error-codes/guide.zh.md | 23 +++++++++++++++++++ .../attachment/attachment/README.i18n.yaml | 4 ++-- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/package.json | 2 ++ packages/attachment/attachment/src/error.ts | 19 ++++++++------- .../attachment/attachment/tests/index.spec.ts | 10 ++++++++ packages/attachment/attachment/tsconfig.json | 3 ++- .../extensions/tool-cordis/src/api-catalog.ts | 6 ++++- packages/fs/tool-fs/package.json | 2 ++ packages/fs/tool-fs/src/image-error.ts | 2 +- packages/fs/tool-fs/src/read-image.ts | 15 +++++++----- packages/fs/tool-fs/tsconfig.json | 3 +++ packages/lsp/lsp-stdio/package.json | 2 ++ packages/lsp/lsp-stdio/src/connection.ts | 2 +- packages/lsp/lsp-stdio/src/index.ts | 2 +- .../lsp/lsp-stdio/tests/connection.spec.ts | 2 +- packages/lsp/lsp-stdio/tsconfig.json | 3 +++ pnpm-lock.yaml | 9 ++++++++ 23 files changed, 138 insertions(+), 32 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md diff --git a/docs/module-graph.md b/docs/module-graph.md index 1f8dfaee91..883e28cd71 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -490,6 +490,7 @@ flowchart TD pkg_session --> pkg_scope pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_attachment --> pkg_error pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand @@ -649,6 +650,7 @@ flowchart TD pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_error pkg_lsp_stdio --> pkg_fs pkg_lsp_stdio --> pkg_llm pkg_lsp_stdio --> pkg_lsp @@ -861,6 +863,7 @@ flowchart TD pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment + pkg_tool_fs --> pkg_error pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_sandbox @@ -1691,7 +1694,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`error`](../packages/util/error) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | -| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | @@ -1761,7 +1764,7 @@ flowchart TD | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | `experimental` | [`experimental-translator`](../packages/experimental/translator) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | @@ -1806,7 +1809,7 @@ flowchart TD | [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`tools`](../packages/core/tools) | | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index a6070b4f5e..c65ef9fdb4 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -492,6 +492,7 @@ flowchart TD pkg_session --> pkg_scope pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_attachment --> pkg_error pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand @@ -651,6 +652,7 @@ flowchart TD pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_error pkg_lsp_stdio --> pkg_fs pkg_lsp_stdio --> pkg_llm pkg_lsp_stdio --> pkg_lsp @@ -863,6 +865,7 @@ flowchart TD pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment + pkg_tool_fs --> pkg_error pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_sandbox @@ -1693,7 +1696,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`error`](../packages/util/error) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | -| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | @@ -1763,7 +1766,7 @@ flowchart TD | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | `experimental` | [`experimental-translator`](../packages/experimental/translator) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | @@ -1808,7 +1811,7 @@ flowchart TD | [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`tools`](../packages/core/tools) | | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml new file mode 100644 index 0000000000..07aac6cd05 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md +/: + en: 8a0a1ff4ca3ad446 + zh: 4bd6cd51cbd791c2 +/attachment-failures-keep-their-codes: + en: 4f2b2f0db0646d3b + zh: 9467f41336a65c48 +/attachment-failures-keep-their-codes/change: + en: 1e5560d944ebe67d + zh: 235f3ec670b01ae2 +/attachment-failures-keep-their-codes/migration: + en: bceb35b3d8c551c4 + zh: e865e7752bec8d13 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md new file mode 100644 index 0000000000..8c416e5537 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "AttachmentError now extends HarnessError, so tool and model-request failures record attachment codes instead of losing them or reporting UNKNOWN." +--- + +# Attachment failures keep their codes + +English | [中文](guide.zh.md) + +## Change + +`AttachmentError` from `@deepseek-ai/dsh-attachment` now extends `HarnessError` from `@deepseek-ai/dsh-error`. Its `name` (`AttachmentError`) and every `ATTACHMENT_*` and image admission code are unchanged. Two persisted results change: + +- When a model request fails because an adapter could not read an image attachment, the failed turn's `turn/end` error previously recorded `code: 'UNKNOWN'`. It now records the attachment code, such as `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, or `ATTACHMENT_READ_FAILED`. +- A tool whose body lets an `AttachmentError` escape now records `error: { name: 'AttachmentError', code }` on its `tool/result`; previously the result had no `error`. `read_image` converts attachment failures to `ImageInputError` codes instead. + +`instanceof HarnessError` now matches `AttachmentError`. `isAttachmentError` is unchanged. + +## Migration + +1. If analytics or retry rules treat `UNKNOWN` model failures as a catch-all, add the attachment codes or route them by their `ATTACHMENT_` prefix. +2. If code catches `HarnessError` before `AttachmentError`, move the `AttachmentError` check first wherever the two need different handling. +3. Earlier Session logs keep `UNKNOWN`; readers that compare old and new logs should treat both as the same failure class for attachment reads. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md new file mode 100644 index 0000000000..3b9ab5145e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "AttachmentError 现在继承 HarnessError,工具失败与模型请求失败会记录附件错误码,不再丢失或报告为 UNKNOWN。" +--- + +# 附件失败保留其错误码 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-attachment` 的 `AttachmentError` 现在继承 `@deepseek-ai/dsh-error` 中的 `HarnessError`。其 `name`(`AttachmentError`)以及所有 `ATTACHMENT_*` 与图像准入错误码均保持不变。两类持久化结果会变化: + +- 适配器无法读取图像附件而导致模型请求失败时,失败 turn 的 `turn/end` 错误过去记录 `code: 'UNKNOWN'`。现在记录附件错误码,例如 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT` 或 `ATTACHMENT_READ_FAILED`。 +- 工具主体让 `AttachmentError` 逸出时,其 `tool/result` 现在记录 `error: { name: 'AttachmentError', code }`;过去结果中没有 `error`。`read_image` 则把附件失败转换为 `ImageInputError` 错误码。 + +`instanceof HarnessError` 现在会匹配 `AttachmentError`。`isAttachmentError` 不变。 + +## 迁移 + +1. 如果分析或重试规则把 `UNKNOWN` 模型失败当作兜底类别,请加入这些附件错误码,或按 `ATTACHMENT_` 前缀路由。 +2. 如果代码先捕获 `HarnessError` 再捕获 `AttachmentError`,在两者需要不同处理的地方把 `AttachmentError` 检查移到前面。 +3. 较早的 Session 日志仍保留 `UNKNOWN`;对比新旧日志的读取方应把附件读取的这两种记录视为同一类失败。 diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 125d96125d..44c9125f4f 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -36,8 +36,8 @@ en: 25d9c7d6c0899d23 zh: ad8380d14810ff1c /deepseek-ai-dsh-attachment/understand-the-implementation/design-decisions: - en: 6315b2d1fb12f0ca - zh: c3f99f4c0850fb44 + en: bc4586bb4ce74464 + zh: 5ffd0a0e7261bc24 /deepseek-ai-dsh-attachment/understand-the-implementation/service-operations: en: b94d13fe133d8ce6 zh: dd4064d20d510afc diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 0e0c2bc05f..075094dcc8 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -67,7 +67,7 @@ This section explains the design decisions behind the seam and the service opera - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. - **Verify on read.** Reads verify the immutable object against the supplied reference without consulting a Session. Optional display names and pre-normalization dimensions pass through as caller-supplied display information; the stored bytes do not verify these fields. - **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions at an explicit route-chosen target size and byte target, while execution filesystems may map the immutable host object to a model-readable process path. -- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. +- **Error routing by code.** `AttachmentError` extends `HarnessError` from the dependency-free `dsh-error`, so tool results keep its `name` and `code`; consumers use `isAttachmentError`, which matches the code across duplicate installations, and route on `code`. - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### Service operations diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 9c9d2c7fb5..400cc79c1e 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 - **读取时校验。** 读取会把不可变对象与传入引用比对,无需查询 Session。可选的显示名称和规范化前尺寸作为调用方提交的显示信息原样返回;存储字节不校验这两个字段。 - **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 -- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 +- **按错误码路由。** `AttachmentError` 继承无依赖包 `dsh-error` 中的 `HarnessError`,因此工具结果保留其 `name` 与 `code`;消费方用 `isAttachmentError` 识别错误(它按错误码匹配,跨重复安装也有效),并按 `code` 路由。 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。 ### 服务操作 diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 79c35fd196..dc831253b1 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -23,10 +23,12 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 0aed793f35..2a933daa08 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -1,5 +1,7 @@ /** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */ +import { HarnessError } from '@deepseek-ai/dsh-error' + const IMAGE_ADMISSION_ERROR_CODES = [ 'TOO_MANY_IMAGES', 'IMAGES_TOO_LARGE', @@ -35,17 +37,14 @@ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISS const ATTACHMENT_ERROR_CODE_SET: ReadonlySet = new Set(ATTACHMENT_ERROR_CODES) /** - * Stable failures suitable for host RPC error mapping. - * - * Deliberately re-implements the `HarnessError` shape instead of extending it: - * the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this - * package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base - * would create a dependency cycle. Consumers route on `code`, never on the - * prototype chain, so the shapes stay interchangeable at the wire boundary. + * Stable failures suitable for host RPC error mapping. Extends {@link HarnessError}, so a tool + * result that fails with it records `name` `AttachmentError` and its `code`. Consumers that may + * receive an error from a duplicate package installation identify it with {@link isAttachmentError}, + * which matches the code instead of the prototype chain. */ -export class AttachmentError extends Error { +export class AttachmentError extends HarnessError { /** Stable machine-routing failure code. */ - readonly code: AttachmentErrorCode + override readonly code: AttachmentErrorCode /** * @param message - human-readable failure description without raw bytes or host paths. @@ -53,7 +52,7 @@ export class AttachmentError extends Error { * @param options - optional chained cause. */ constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions) { - super(message, options) + super(message, code, options) this.name = 'AttachmentError' this.code = code } diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 9ce59d2d48..159dafd61f 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' +import { HarnessError } from '@deepseek-ai/dsh-error' import AttachmentStore, { AttachmentError, AttachmentId, @@ -218,6 +219,15 @@ describe('isImageAdmissionError', () => { }) }) +describe('AttachmentError', () => { + it('is a HarnessError that keeps its literal name and attachment code', () => { + const cause = new Error('disk full') + const error = new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED', { cause }) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'AttachmentError', code: 'ATTACHMENT_WRITE_FAILED', message: 'disk failed', cause }) + }) +}) + describe('isAttachmentError', () => { it('recognizes attachment failures from another package installation by code', () => { expect(isAttachmentError(new AttachmentError('bad base64', 'INVALID_FILE_BASE64'))).toBe(true) diff --git a/packages/attachment/attachment/tsconfig.json b/packages/attachment/attachment/tsconfig.json index 33e1dd58de..3efee044ab 100644 --- a/packages/attachment/attachment/tsconfig.json +++ b/packages/attachment/attachment/tsconfig.json @@ -5,6 +5,7 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, - { "path": "../../util/brand" } + { "path": "../../util/brand" }, + { "path": "../../util/error" } ] } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c5d2a12f4a..b67d999481 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5818,7 +5818,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AttachmentError', - declaration: 'export class AttachmentError extends Error {\n readonly code: AttachmentErrorCode;\n constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions);\n}', + declaration: 'export class AttachmentError extends HarnessError {\n override readonly code: AttachmentErrorCode;\n constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions);\n}', }, { name: 'AttachmentErrorCode', @@ -6792,6 +6792,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GrantRecord', declaration: 'export interface GrantRecord {\n readonly kind: \'grant\';\n readonly payload: unknown;\n}', }, + { + name: 'HarnessError', + declaration: 'export class HarnessError extends Error {\n readonly code: string;\n constructor(message: string, code: string, options?: ErrorOptions);\n}', + }, { name: 'HookBudget', declaration: 'export interface HookBudget {\n readonly ms: number;\n readonly remainingMs: number;\n}', diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 331615dce5..7c33681fa8 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/dsh-util-values": "workspace:*" }, "peerDependencies": { + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", @@ -52,6 +53,7 @@ "@deepseek-ai/dsh-working-directory": "workspace:*" }, "devDependencies": { + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", diff --git a/packages/fs/tool-fs/src/image-error.ts b/packages/fs/tool-fs/src/image-error.ts index 102942e9c6..b9839110fc 100644 --- a/packages/fs/tool-fs/src/image-error.ts +++ b/packages/fs/tool-fs/src/image-error.ts @@ -1,6 +1,6 @@ /** Typed `read_image` failures. @module @deepseek-ai/dsh-tool-fs/src/image-error */ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' /** * Every machine-routable `read_image` failure code: diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index b8bacbcba4..159cb2409c 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -16,7 +16,7 @@ import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' @@ -217,21 +217,24 @@ interface SaveFailureFacts { /** * Classify one `saveImage` rejection as the error `read_image` throws. - * An `AbortError` and an already classified `HarnessError` pass through - * unchanged; attachment admission codes become {@link ImageInputError} codes, - * and every other failure becomes `IMAGE_STORE_FAILED` with the original as `cause`. + * Attachment admission codes become {@link ImageInputError} codes and other + * `AttachmentError` codes become `IMAGE_STORE_FAILED`; any other `HarnessError` + * and an `AbortError` pass through unchanged; every other failure becomes + * `IMAGE_STORE_FAILED`. Converted errors keep the original as `cause`. * @param error - the value `saveImage` rejected with. * @param attachments - the store whose limits the refusal names. * @param facts - the path and media-type facts the refusal names. * @returns the error to throw from the tool body. */ function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFailureFacts): unknown { - if (error instanceof HarnessError || (error instanceof Error && error.name === 'AbortError')) return error const { displayPath, extension, mediaType, sniffed } = facts const refusal = (detail: string, code: ImageInputError['code']) => new ImageInputError(`cannot read "${displayPath}": ${detail}`, code, { cause: error }) const storeFailed = (detail: string) => refusal(`the attachment store could not save the image: ${detail}`, 'IMAGE_STORE_FAILED') - if (!(error instanceof AttachmentError)) return storeFailed(error instanceof Error ? error.message : String(error)) + if (!(error instanceof AttachmentError)) { + if (error instanceof HarnessError || (error instanceof Error && error.name === 'AbortError')) return error + return storeFailed(error instanceof Error ? error.message : String(error)) + } // Size refusals stay recoverable tool errors: an oversized image must never // enter durable history, where it would ride every later model request past // provider-side dimension rejections. diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index 69d7101ff5..8d4c6539b0 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/error" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 670566cf85..494db76732 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -32,6 +32,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", @@ -45,6 +46,7 @@ "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index e910ac0a52..c0c1fa2a8c 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -12,7 +12,7 @@ import type { Writable } from 'node:stream' import { LspError } from '@deepseek-ai/dsh-lsp' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index 1e7fe51591..eee4962849 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -14,7 +14,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import type { LspProvider, LspProviderQuery, diff --git a/packages/lsp/lsp-stdio/tests/connection.spec.ts b/packages/lsp/lsp-stdio/tests/connection.spec.ts index 9428db7ad7..d226e6e8e3 100644 --- a/packages/lsp/lsp-stdio/tests/connection.spec.ts +++ b/packages/lsp/lsp-stdio/tests/connection.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { LspConnection } from '@deepseek-ai/dsh-lsp-stdio' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' diff --git a/packages/lsp/lsp-stdio/tsconfig.json b/packages/lsp/lsp-stdio/tsconfig.json index d133816fda..8487fd28cf 100644 --- a/packages/lsp/lsp-stdio/tsconfig.json +++ b/packages/lsp/lsp-stdio/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/error" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cacf7aa38f..1eb0a0f5a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1968,6 +1968,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error packages/attachment/attachment-local: dependencies: @@ -12272,6 +12275,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:* version: link:../../attachment/attachment + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-fs': specifier: workspace:* version: link:../fs @@ -13630,6 +13636,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-fs': specifier: workspace:* version: link:../../fs/fs From 1a3d59a0ff4d1b68a01514d3a9e6c5fe2e13e5eb Mon Sep 17 00:00:00 2001 From: dsh-ci-bot Date: Fri, 6 Nov 2026 16:06:14 +0800 Subject: [PATCH 10/81] test(ui-theme): skip dot directories in the package scan The hover-styles contract walks packages/ for package.json files. Typert generator specs create temporary roots inside that tree with mkdtempSync(join(import.meta.dirname, '....')) and remove them in afterEach, so a concurrent scan can readdir one and then hit ENOENT when its owner deletes it. Dot directories hold VCS metadata and those transient roots, never shipped source, so skip them and let the helper walk an explicit root for the regression test. --- .../ui-theme/tests/stylesheet-scan.spec.ts | 23 +++++++++++++++++++ .../client/ui-theme/tests/stylesheet-scan.ts | 12 ++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 packages/client/ui-theme/tests/stylesheet-scan.spec.ts diff --git a/packages/client/ui-theme/tests/stylesheet-scan.spec.ts b/packages/client/ui-theme/tests/stylesheet-scan.spec.ts new file mode 100644 index 0000000000..1614ee55a7 --- /dev/null +++ b/packages/client/ui-theme/tests/stylesheet-scan.spec.ts @@ -0,0 +1,23 @@ +/** Tests for the package-tree scan helpers that the stylesheet contracts range over. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { packageFiles } from './stylesheet-scan.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +it('walks shipped package files and skips dot directories holding transient roots', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-stylesheet-scan-')) + roots.push(root) + mkdirSync(join(root, 'pkg')) + writeFileSync(join(root, 'pkg', 'package.json'), '{}') + mkdirSync(join(root, '.typert-worker-failure-probe')) + writeFileSync(join(root, '.typert-worker-failure-probe', 'package.json'), '{}') + + expect(packageFiles(name => name === 'package.json', root)).toEqual([`${root}/pkg/package.json`]) +}) diff --git a/packages/client/ui-theme/tests/stylesheet-scan.ts b/packages/client/ui-theme/tests/stylesheet-scan.ts index 91311945bf..0ddb93e0f8 100644 --- a/packages/client/ui-theme/tests/stylesheet-scan.ts +++ b/packages/client/ui-theme/tests/stylesheet-scan.ts @@ -75,20 +75,24 @@ export function varReferences(value: string): string[] { * Every file shipped as package source whose name the predicate accepts, * excluding build output and installed dependencies. * @param accepts - filename predicate. - * @returns absolute paths under packages/, `/`-separated on every platform so + * @param root - tree to walk, the packages directory by default. + * @returns absolute paths under `root`, `/`-separated on every platform so * specs can match repo-relative suffixes verbatim. */ -export function packageFiles(accepts: (name: string) => boolean): string[] { +export function packageFiles(accepts: (name: string) => boolean, root: string = PACKAGES_DIR): string[] { const found: string[] = [] const walk = (dir: string): void => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name) if (entry.isDirectory()) { - if (entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path) + // Dot directories hold VCS metadata and per-test temporary roots (for + // example `.typert-worker-failure-*`), never shipped package source; a + // concurrent owner can remove one while a scan reads it. + if (!entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path) } else if (accepts(entry.name)) found.push(path.replaceAll('\\', '/')) } } - walk(PACKAGES_DIR) + walk(root) return found } From c2fd342d33cb214a1fb04e619496d395d044580e Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 16:19:10 +0800 Subject: [PATCH 11/81] fix(fs): address review feedback on read_image and create errors - read_image converts only AttachmentError rejections from saveImage; any other value is a store defect or cancellation and propagates unchanged instead of becoming IMAGE_STORE_FAILED. - ImageInputError is no longer exported from the dsh-tool-fs entry, and its JSDoc notes that two codes share AttachmentError spellings. - str_replace_editor create maps a guarded-creation collision (FS_NOT_OBSERVED after a concurrent create) to FS_ALREADY_EXISTS. - The fs-local directory-swap test asserts FS_NOT_REGULAR_FILE exactly. - The attachment upgrade guide lists the full AttachmentErrorCode set instead of an ATTACHMENT_ prefix. - dsh-error dependency entries sit in sorted position. --- docs/config-catalog.md | 4 ++-- docs/config-catalog.zh.md | 4 ++-- .../attachment-error-codes/guide.i18n.yaml | 8 ++++---- .../attachment-error-codes/guide.md | 4 ++-- .../attachment-error-codes/guide.zh.md | 4 ++-- packages/fs/fs-local/tests/fsio.spec.ts | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 ++-- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/fs/tool-fs/package.json | 4 ++-- packages/fs/tool-fs/src/image-error.ts | 6 +++++- packages/fs/tool-fs/src/index.ts | 2 -- packages/fs/tool-fs/src/read-image.ts | 12 ++++-------- packages/fs/tool-fs/tests/read-image.spec.ts | 15 +++++---------- .../fs/tool-str-replace-editor/src/index.ts | 11 ++++++++--- .../tests/tools.spec.ts | 18 ++++++++++++++++++ 16 files changed, 59 insertions(+), 43 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 527e193db9..81170e9d3d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4155,7 +4155,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-fs` - `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory` -- `source`: [`packages/fs/tool-fs/src/index.ts:29`](../packages/fs/tool-fs/src/index.ts) +- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ @@ -4460,7 +4460,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-str-replace-editor` - `inject`: `tools` · `fs` -- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts) +- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:511`](../packages/fs/tool-str-replace-editor/src/index.ts) ```ts config-catalog /** Configuration for the string-replacement editor tool. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 76218f3d03..53619fd6e7 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4157,7 +4157,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-fs` - `inject`: `tools` · `fs` · `systemPrompt` · `workingDirectory` -- `source`: [`packages/fs/tool-fs/src/index.ts:29`](../packages/fs/tool-fs/src/index.ts) +- `source`: [`packages/fs/tool-fs/src/index.ts:27`](../packages/fs/tool-fs/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ @@ -4462,7 +4462,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-str-replace-editor` - `inject`: `tools` · `fs` -- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts) +- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:511`](../packages/fs/tool-str-replace-editor/src/index.ts) ```ts config-catalog /** Configuration for the string-replacement editor tool. */ diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml index 07aac6cd05..091cae83fa 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 4f2b2f0db0646d3b zh: 9467f41336a65c48 /attachment-failures-keep-their-codes/change: - en: 1e5560d944ebe67d - zh: 235f3ec670b01ae2 + en: daa5a93cf418a352 + zh: 3b391fbb4a87f2b1 /attachment-failures-keep-their-codes/migration: - en: bceb35b3d8c551c4 - zh: e865e7752bec8d13 + en: 8599ead7a17bc7eb + zh: 1120fd02065567f5 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md index 8c416e5537..263ddf5df8 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md @@ -11,13 +11,13 @@ English | [中文](guide.zh.md) `AttachmentError` from `@deepseek-ai/dsh-attachment` now extends `HarnessError` from `@deepseek-ai/dsh-error`. Its `name` (`AttachmentError`) and every `ATTACHMENT_*` and image admission code are unchanged. Two persisted results change: -- When a model request fails because an adapter could not read an image attachment, the failed turn's `turn/end` error previously recorded `code: 'UNKNOWN'`. It now records the attachment code, such as `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, or `ATTACHMENT_READ_FAILED`. +- When a model request fails because an adapter could not read an image attachment, the failed turn's `turn/end` error previously recorded `code: 'UNKNOWN'`. It now records the `AttachmentError` code, such as `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, `ATTACHMENT_READ_FAILED`, or an image admission code such as `INVALID_IMAGE` or `IMAGE_TOO_LARGE`. - A tool whose body lets an `AttachmentError` escape now records `error: { name: 'AttachmentError', code }` on its `tool/result`; previously the result had no `error`. `read_image` converts attachment failures to `ImageInputError` codes instead. `instanceof HarnessError` now matches `AttachmentError`. `isAttachmentError` is unchanged. ## Migration -1. If analytics or retry rules treat `UNKNOWN` model failures as a catch-all, add the attachment codes or route them by their `ATTACHMENT_` prefix. +1. If analytics or retry rules treat `UNKNOWN` model failures as a catch-all, add the codes of the exported `AttachmentErrorCode` type; in code, identify these failures with `isAttachmentError`. The codes do not share one prefix. 2. If code catches `HarnessError` before `AttachmentError`, move the `AttachmentError` check first wherever the two need different handling. 3. Earlier Session logs keep `UNKNOWN`; readers that compare old and new logs should treat both as the same failure class for attachment reads. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md index 3b9ab5145e..674f00805b 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md @@ -11,13 +11,13 @@ description: "AttachmentError 现在继承 HarnessError,工具失败与模型 `@deepseek-ai/dsh-attachment` 的 `AttachmentError` 现在继承 `@deepseek-ai/dsh-error` 中的 `HarnessError`。其 `name`(`AttachmentError`)以及所有 `ATTACHMENT_*` 与图像准入错误码均保持不变。两类持久化结果会变化: -- 适配器无法读取图像附件而导致模型请求失败时,失败 turn 的 `turn/end` 错误过去记录 `code: 'UNKNOWN'`。现在记录附件错误码,例如 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT` 或 `ATTACHMENT_READ_FAILED`。 +- 适配器无法读取图像附件而导致模型请求失败时,失败 turn 的 `turn/end` 错误过去记录 `code: 'UNKNOWN'`。现在记录 `AttachmentError` 的错误码,例如 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT`、`ATTACHMENT_READ_FAILED`,或 `INVALID_IMAGE`、`IMAGE_TOO_LARGE` 等图像准入错误码。 - 工具主体让 `AttachmentError` 逸出时,其 `tool/result` 现在记录 `error: { name: 'AttachmentError', code }`;过去结果中没有 `error`。`read_image` 则把附件失败转换为 `ImageInputError` 错误码。 `instanceof HarnessError` 现在会匹配 `AttachmentError`。`isAttachmentError` 不变。 ## 迁移 -1. 如果分析或重试规则把 `UNKNOWN` 模型失败当作兜底类别,请加入这些附件错误码,或按 `ATTACHMENT_` 前缀路由。 +1. 如果分析或重试规则把 `UNKNOWN` 模型失败当作兜底类别,请加入导出类型 `AttachmentErrorCode` 中的错误码;代码中用 `isAttachmentError` 识别这些失败。这些错误码没有统一前缀。 2. 如果代码先捕获 `HarnessError` 再捕获 `AttachmentError`,在两者需要不同处理的地方把 `AttachmentError` 检查移到前面。 3. 较早的 Session 日志仍保留 `UNKNOWN`;对比新旧日志的读取方应把附件读取的这两种记录视为同一类失败。 diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 980f7a722c..4d4f9a3748 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -1058,7 +1058,7 @@ describe('Node errno classification', () => { }, }).then(() => undefined, (caught: unknown) => caught) expect(failure).toBeInstanceOf(FsError) - expect(['FS_NOT_REGULAR_FILE', 'FS_IO_ERROR']).toContain((failure as FsError).code) + expect(failure).toMatchObject({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE', cause: expect.objectContaining({ code: 'EISDIR' }) as Error }) }) it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('maps unreadable file content to FS_PERMISSION_DENIED', async () => { diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 15125f4f7e..e8c96ec826 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -30,8 +30,8 @@ en: 9bddebaf4aff0a8d zh: fea0196162e3fcd1 /deepseek-ai-dsh-tool-fs/use-this-package/failures-and-recovery: - en: 177d25a1e8f7afbd - zh: 0303980d6b2789c3 + en: 06fe9bd1d49c1d53 + zh: a8a6963daae11267 /deepseek-ai-dsh-tool-fs/understand-the-implementation: en: e30f7038c28e93f8 zh: a3868dbd90367b13 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index d7e519d577..cf3ef031df 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -83,7 +83,7 @@ With the policy plugin mounted, `write` and `edit` obtain their guard from the ` For `apply_patch`, syntax and preflight refusals throw with `isError: true` before any disk change. `FsError` preserves filesystem codes; `PatchToolError extends HarnessError` carries `PATCH_SYNTAX` and `PATCH_SYMLINK_SOURCE`. Once commit starts, the tool returns a value with `isError: false` even on failure or uncertainty, so known partial effects retain their diff metadata; a call cancelled during commit receives the tool registry's abort result like every other tool, and its completed changes remain visible through Review. `phase`, `cancelled`, ordered `operations` and `affectedPaths` describe the commit; each operation retains its index, kind, display paths, patch line, status, diagnostic and known changes. Full `before`/`after` text serves rendering and PTC; the Session stores the rendered result plus `meta.diffs` and `meta.stopped`. Creations store no diff, as for `write`; deletions and move sources store a removal diff only when the provider returned a bounded basis, using `newText: ''` because `FileDiff.newText` cannot be null. -`read_image` refusals throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model), `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file), `IMAGE_TYPE_MISMATCH` (rename or convert the file), `IMAGE_INVALID` (truncated or corrupt bytes), `IMAGE_TOO_LARGE` (downscale the image), `IMAGE_CONVERSION_FAILED` (a 16-bit PNG could not be normalized; convert it to 8-bit), and `IMAGE_STORE_FAILED` (any other attachment-store failure, kept as `cause`). An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; an `AbortError` or another `HarnessError` from the store passes through unchanged. +`read_image` refusals throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model), `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file), `IMAGE_TYPE_MISMATCH` (rename or convert the file), `IMAGE_INVALID` (truncated or corrupt bytes), `IMAGE_TOO_LARGE` (downscale the image), `IMAGE_CONVERSION_FAILED` (a 16-bit PNG could not be normalized; convert it to 8-bit), and `IMAGE_STORE_FAILED` (any other attachment-store failure, kept as `cause`). An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; any store failure other than an `AttachmentError`, such as an `AbortError`, another `HarnessError`, or a defect, passes through unchanged. Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include the `ToolArgsError` (`INVALID_ARGS`) messages `invalid arguments: file_path must be a non-empty string` and `invalid arguments: limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. `FS_NOT_OBSERVED` is normalized to `cannot modify "": file has not been read — read the file, then retry`, independent of whether the policy or provider rejected the operation; `FS_STALE_VERSION` retains the provider's reason and appends `— re-read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 7e81223b57..09af000f07 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" `apply_patch` 的语法与预检拒绝在磁盘变更前抛错,结果为 `isError: true`。`FsError` 保留文件系统错误码;`PatchToolError extends HarnessError` 携带 `PATCH_SYNTAX` 和 `PATCH_SYMLINK_SOURCE`。进入提交后,即使失败或结果不确定也返回 `isError: false` 的值,确保已知部分效果的 diff 元数据得以保留;提交期间被取消的调用与其他工具一样收到工具注册表的中止结果,已完成的变更仍可通过 Review 查看。`phase`、`cancelled`、有序 `operations` 与 `affectedPaths` 描述提交;每条操作保留索引、种类、展示路径、补丁行号、状态、诊断与已知变更。完整 `before`/`after` 文本供渲染和 PTC 使用;Session 保存渲染文本以及 `meta.diffs` 与 `meta.stopped`。新建与 `write` 一样不保存 diff;删除与移动源仅在提供方返回有界基线时保存删除 diff,并使用 `newText: ''`,因为 `FileDiff.newText` 不可为 null。 -`read_image` 的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)、`IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)、`IMAGE_TYPE_MISMATCH`(重命名或转换文件)、`IMAGE_INVALID`(字节被截断或损坏)、`IMAGE_TOO_LARGE`(缩小图像)、`IMAGE_CONVERSION_FAILED`(16 位 PNG 无法规范化;转换为 8 位)以及 `IMAGE_STORE_FAILED`(其他附件存储失败,原错误保留为 `cause`)。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的 `AbortError` 或其他 `HarnessError` 原样传递。 +`read_image` 的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)、`IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)、`IMAGE_TYPE_MISMATCH`(重命名或转换文件)、`IMAGE_INVALID`(字节被截断或损坏)、`IMAGE_TOO_LARGE`(缩小图像)、`IMAGE_CONVERSION_FAILED`(16 位 PNG 无法规范化;转换为 8 位)以及 `IMAGE_STORE_FAILED`(其他附件存储失败,原错误保留为 `cause`)。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的 `AttachmentError` 以外的失败(例如 `AbortError`、其他 `HarnessError` 或缺陷)原样传递。 失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `ToolArgsError`(`INVALID_ARGS`)消息 `invalid arguments: file_path must be a non-empty string` 与 `invalid arguments: limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。无论拒绝来自策略还是提供方,`FS_NOT_OBSERVED` 都规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 7c33681fa8..e49dad494a 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -39,8 +39,8 @@ "@deepseek-ai/dsh-util-values": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", @@ -53,11 +53,11 @@ "@deepseek-ai/dsh-working-directory": "workspace:*" }, "devDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", diff --git a/packages/fs/tool-fs/src/image-error.ts b/packages/fs/tool-fs/src/image-error.ts index b9839110fc..a19a0b0f8d 100644 --- a/packages/fs/tool-fs/src/image-error.ts +++ b/packages/fs/tool-fs/src/image-error.ts @@ -29,7 +29,11 @@ export const IMAGE_INPUT_ERROR_CODES = [ /** One `read_image` failure code. */ export type ImageInputErrorCode = typeof IMAGE_INPUT_ERROR_CODES[number] -/** Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. */ +/** + * Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. `IMAGE_TOO_LARGE` and + * `IMAGE_TYPE_MISMATCH` share their spelling with `AttachmentError` codes, so consumers route on `name` + * and `code` together, and code-only predicates such as `isAttachmentError` do not identify this class. + */ export class ImageInputError extends HarnessError { override readonly code: ImageInputErrorCode diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 9e3e3095d5..41e3be251c 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -17,8 +17,6 @@ import { applyReadImageTool } from './read-image.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' import { FsSandboxController } from './sandbox.ts' -export { ImageInputError, type ImageInputErrorCode } from './image-error.ts' - /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 159cb2409c..9f7d714ed6 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -16,7 +16,6 @@ import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { HarnessError } from '@deepseek-ai/dsh-error' import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' @@ -218,9 +217,9 @@ interface SaveFailureFacts { /** * Classify one `saveImage` rejection as the error `read_image` throws. * Attachment admission codes become {@link ImageInputError} codes and other - * `AttachmentError` codes become `IMAGE_STORE_FAILED`; any other `HarnessError` - * and an `AbortError` pass through unchanged; every other failure becomes - * `IMAGE_STORE_FAILED`. Converted errors keep the original as `cause`. + * `AttachmentError` codes become `IMAGE_STORE_FAILED`, keeping the original as + * `cause`. The attachment store reports its own failures as `AttachmentError`, + * so any other value, including cancellation, passes through unchanged. * @param error - the value `saveImage` rejected with. * @param attachments - the store whose limits the refusal names. * @param facts - the path and media-type facts the refusal names. @@ -231,10 +230,7 @@ function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFa const refusal = (detail: string, code: ImageInputError['code']) => new ImageInputError(`cannot read "${displayPath}": ${detail}`, code, { cause: error }) const storeFailed = (detail: string) => refusal(`the attachment store could not save the image: ${detail}`, 'IMAGE_STORE_FAILED') - if (!(error instanceof AttachmentError)) { - if (error instanceof HarnessError || (error instanceof Error && error.name === 'AbortError')) return error - return storeFailed(error instanceof Error ? error.message : String(error)) - } + if (!(error instanceof AttachmentError)) return error // Size refusals stay recoverable tool errors: an oversized image must never // enter durable history, where it would ride every later model request past // provider-side dimension rejections. diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 6c2feac7a4..bced7b466e 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -699,18 +699,13 @@ describe('image admission failures', () => { expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": ${detail}`) }) - it('wraps a non-attachment save failure as IMAGE_STORE_FAILED', async () => { - const result = await failingSave(() => new Error('unrelated infrastructure failure')) - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) - expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": the attachment store could not save the image: unrelated infrastructure failure`) + it('passes a store defect, a classified HarnessError, and an AbortError through unchanged', async () => { + const defect = await failingSave(() => new Error('unrelated infrastructure failure')) + expect(defect.isError).toBe(true) + expect(defect.error?.info).toBeUndefined() + expect(text(defect)).toBe('Error: unrelated infrastructure failure') - const nonError = await failingSave(() => 'string failure') - expect(nonError.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) - expect(text(nonError)).toContain('the attachment store could not save the image: string failure') - }) - - it('passes a classified HarnessError and an AbortError through unchanged', async () => { const fsFailure = await failingSave(() => new FsError('cannot read "x": permission denied', 'FS_PERMISSION_DENIED')) expect(fsFailure.error?.info).toEqual({ name: 'FsError', code: 'FS_PERMISSION_DENIED' }) expect(text(fsFailure)).toBe('Error: cannot read "x": permission denied') diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 099d1a6d73..fa3c5a74e6 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -248,9 +248,12 @@ async function createFile( const content = requiredForCommand(fileText, 'file_text', 'create') const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, exec.signal) - if (await ctx.fs.stat(target, exec.signal) !== undefined) { - throw new FsError(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`, 'FS_ALREADY_EXISTS') - } + const alreadyExists = (cause?: FsError) => new FsError( + `File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`, + 'FS_ALREADY_EXISTS', + cause === undefined ? undefined : { cause }, + ) + if (await ctx.fs.stat(target, exec.signal) !== undefined) throw alreadyExists() const intent = await ctx.waterfall( 'fs/write-intent', target, @@ -267,6 +270,8 @@ async function createFile( sandboxPolicy, ) } catch (error: unknown) { + // A file created after the absence check makes guarded creation report it as unread. + if (error instanceof FsError && error.code === 'FS_NOT_OBSERVED') throw alreadyExists(error) throw policy.mapError(error, sandboxPolicy) } ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index e08c539c59..d780b856fb 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -632,6 +632,24 @@ describe('tool-str-replace-editor', () => { .rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing') }) + it('reports a file created after the absence check as FS_ALREADY_EXISTS', async () => { + const { ctx, root, owner } = await setup() + const path = join(root, 'raced.txt') + await writeFile(path, 'concurrent') + const stat = ctx.fs.stat.bind(ctx.fs) + let first = true + ctx.fs.stat = async (target, signal) => { + if (!first) return stat(target, signal) + first = false + return undefined + } + const result = await call(ctx, owner, { command: 'create', path, file_text: 'mine' }) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_ALREADY_EXISTS' }) + expect(text(result)).toContain(`File already exists at: ${path}`) + expect(await readFile(path, 'utf8')).toBe('concurrent') + }) + it('maps unexpected backend write failures for replace and insert', async () => { const { ctx, root, owner } = await setup() const path = join(root, 'backend-error.txt') From e80eb062a14c838aeaee98e34537f8e5e9bf6879 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 16:19:12 +0800 Subject: [PATCH 12/81] fix(lsp): derive query capabilities from the handshake result The instance's ready promise now resolves to the negotiated server capabilities, so a query can no longer observe an instance without them; LSP_NOT_INITIALIZED and its private-state test are removed. The LSP failures page qualifies the teardown combination (uncoded query failures surface as AggregateError without error.info) and the stderr tail of LSP_SERVER_EXITED, and LspQueryTeardownError documents that it does not keep the query failure's class. --- docs/subsystems/lsp.i18n.yaml | 4 ++-- docs/subsystems/lsp.md | 5 ++--- docs/subsystems/lsp.zh.md | 5 ++--- packages/lsp/lsp-stdio/README.i18n.yaml | 4 ++-- packages/lsp/lsp-stdio/README.md | 2 +- packages/lsp/lsp-stdio/README.zh.md | 2 +- packages/lsp/lsp-stdio/package.json | 4 ++-- packages/lsp/lsp-stdio/src/index.ts | 3 ++- packages/lsp/lsp-stdio/src/instance.ts | 15 ++++++--------- packages/lsp/lsp-stdio/tests/instance.spec.ts | 11 ----------- 10 files changed, 20 insertions(+), 35 deletions(-) diff --git a/docs/subsystems/lsp.i18n.yaml b/docs/subsystems/lsp.i18n.yaml index 6d21e0fc70..a89df73725 100644 --- a/docs/subsystems/lsp.i18n.yaml +++ b/docs/subsystems/lsp.i18n.yaml @@ -18,5 +18,5 @@ en: 9977196522f2d7e5 zh: f971ba2c60beda68 /lsp-navigation/failures: - en: 192bd5699c1a23af - zh: f221bff65286231a + en: 50959870fd65acce + zh: 9f9e0c2b04572235 diff --git a/docs/subsystems/lsp.md b/docs/subsystems/lsp.md index 275ba3b236..647fd2920b 100644 --- a/docs/subsystems/lsp.md +++ b/docs/subsystems/lsp.md @@ -167,7 +167,7 @@ interface LspService { ## Failures -Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` and `code` the tool result keeps in `error.info`. `LspError` sets `name` to `LspError`; its `code` stays open because providers and consumers add codes, so consumers tolerate codes they do not know. Invalid tool arguments fail as `ToolArgsError` `INVALID_ARGS`, and caller cancellation as `AbortError`. Workspace and source reads keep the filesystem provider's `FsError` code, such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, or `FS_NOT_TEXT`, and prefix the LSP operand to its message; the stdio provider adds `FS_NOT_DIRECTORY` for a non-directory workspace and `FS_TOO_LARGE` for a source over `maxDocumentBytes`. A query failure whose server teardown also fails keeps the query failure's `name` and `code` and appends the teardown failure to its message. +Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` and `code` the tool result keeps in `error.info`. `LspError` sets `name` to `LspError`; its `code` stays open because providers and consumers add codes, so consumers tolerate codes they do not know. Invalid tool arguments fail as `ToolArgsError` `INVALID_ARGS`, and caller cancellation as `AbortError`. Workspace and source reads keep the filesystem provider's `FsError` code, such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, or `FS_NOT_TEXT`, and prefix the LSP operand to its message; the stdio provider adds `FS_NOT_DIRECTORY` for a non-directory workspace and `FS_TOO_LARGE` for a source over `maxDocumentBytes`. A coded query failure whose server teardown also fails keeps the query failure's `name` and `code` and appends the teardown failure to its message; a query failure without a code, such as a cancellation, surfaces with the teardown failure as an `AggregateError` without `error.info`. | Code | Raised by | Meaning | Consumer action | |---|---|---|---| @@ -181,11 +181,10 @@ Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` an | `LSP_FILE_OUTSIDE_WORKSPACE` | Stdio provider | The source resolves outside the canonical workspace | Query a file inside the workspace | | `LSP_UNSUPPORTED_ENCODING` | Stdio provider | The server negotiated a position encoding other than UTF-16 | Use a server that supports UTF-16 | | `LSP_SPAWN_FAILED` | Stdio provider | The subprocess provider failed to launch or run the server; a provider `HarnessError` keeps its own `name` and `code` instead | Fix the server command or installation | -| `LSP_SERVER_EXITED` | Stdio provider | The server exited or closed its stdin; the message carries the retained stderr tail | Retry later; inspect the stderr tail when it repeats | +| `LSP_SERVER_EXITED` | Stdio provider | The server exited or closed its stdin; after an exit, the message carries the retained stderr tail | Retry later; inspect the stderr tail when it repeats | | `LSP_PROTOCOL_ERROR` | Stdio provider | The server's output broke base-protocol framing or JSON | Report the server defect | | `LSP_MESSAGE_TOO_LARGE` | Stdio provider | A server message exceeded `maxMessageBytes` | Narrow the query or raise `maxMessageBytes` | | `LSP_SERVER_ERROR` | Stdio provider | The server answered with a JSON-RPC error; the message carries its numeric code and text | Act on the server's message | -| `LSP_NOT_INITIALIZED` | Stdio provider | A query found the server instance without negotiated capabilities after its handshake succeeded; this is inconsistent internal state, not a server condition | Report the defect | After a transport failure (`LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`), the stdio provider retries the read-only query once on a fresh process when the failed process tears down cleanly. diff --git a/docs/subsystems/lsp.zh.md b/docs/subsystems/lsp.zh.md index 65612a8eac..7a6ef8d3c6 100644 --- a/docs/subsystems/lsp.zh.md +++ b/docs/subsystems/lsp.zh.md @@ -167,7 +167,7 @@ interface LspService { ## 失败 -`lsp` 工具调用的每个预期失败都是 `HarnessError`,工具结果会把它的 `name` 和 `code` 保留在 `error.info` 中。`LspError` 把 `name` 设为 `LspError`;由于提供方和消费方会添加错误码,它的 `code` 保持开放,消费方须容忍未知错误码。无效的工具参数以 `ToolArgsError` `INVALID_ARGS` 失败,调用方取消以 `AbortError` 失败。工作区与源文件读取保留文件系统提供方的 `FsError` 错误码,例如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 或 `FS_NOT_TEXT`,并在其消息前加上 LSP 操作对象;stdio 提供方对非目录工作区使用 `FS_NOT_DIRECTORY`,对超过 `maxDocumentBytes` 的源文件使用 `FS_TOO_LARGE`。如果查询失败后服务器拆除也失败,错误保留查询失败的 `name` 和 `code`,并把拆除失败追加到消息中。 +`lsp` 工具调用的每个预期失败都是 `HarnessError`,工具结果会把它的 `name` 和 `code` 保留在 `error.info` 中。`LspError` 把 `name` 设为 `LspError`;由于提供方和消费方会添加错误码,它的 `code` 保持开放,消费方须容忍未知错误码。无效的工具参数以 `ToolArgsError` `INVALID_ARGS` 失败,调用方取消以 `AbortError` 失败。工作区与源文件读取保留文件系统提供方的 `FsError` 错误码,例如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 或 `FS_NOT_TEXT`,并在其消息前加上 LSP 操作对象;stdio 提供方对非目录工作区使用 `FS_NOT_DIRECTORY`,对超过 `maxDocumentBytes` 的源文件使用 `FS_TOO_LARGE`。如果带错误码的查询失败后服务器拆除也失败,错误保留查询失败的 `name` 和 `code`,并把拆除失败追加到消息中;不带错误码的查询失败(例如取消)与拆除失败一起以 `AggregateError` 呈现,不带 `error.info`。 | 错误码 | 抛出方 | 含义 | 消费方操作 | |---|---|---|---| @@ -181,11 +181,10 @@ interface LspService { | `LSP_FILE_OUTSIDE_WORKSPACE` | stdio 提供方 | 源文件解析到规范工作区之外 | 查询工作区内的文件 | | `LSP_UNSUPPORTED_ENCODING` | stdio 提供方 | 服务器协商了 UTF-16 以外的位置编码 | 使用支持 UTF-16 的服务器 | | `LSP_SPAWN_FAILED` | stdio 提供方 | 子进程提供方未能启动或运行服务器;提供方的 `HarnessError` 则保留其自身的 `name` 和 `code` | 修正服务器命令或安装 | -| `LSP_SERVER_EXITED` | stdio 提供方 | 服务器已退出或关闭了 stdin;消息携带保留的 stderr 尾部 | 稍后重试;反复出现时检查 stderr 尾部 | +| `LSP_SERVER_EXITED` | stdio 提供方 | 服务器已退出或关闭了 stdin;进程退出时消息携带保留的 stderr 尾部 | 稍后重试;反复出现时检查 stderr 尾部 | | `LSP_PROTOCOL_ERROR` | stdio 提供方 | 服务器输出破坏了基础协议分帧或 JSON | 报告服务器缺陷 | | `LSP_MESSAGE_TOO_LARGE` | stdio 提供方 | 某条服务器消息超过 `maxMessageBytes` | 缩小查询范围或调大 `maxMessageBytes` | | `LSP_SERVER_ERROR` | stdio 提供方 | 服务器以 JSON-RPC 错误应答;消息携带其数字错误码与文本 | 按服务器消息处理 | -| `LSP_NOT_INITIALIZED` | stdio 提供方 | 握手已成功,但查询时服务器实例没有已协商的能力;这是内部状态不一致,不是服务器的情况 | 报告该缺陷 | 传输失败(`LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`)后,如果失败的进程干净地完成拆除,stdio 提供方会在新进程上把只读查询重试一次。 diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index 369a475ea4..f4f7a2db0e 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/README.i18n.yaml @@ -24,8 +24,8 @@ en: 41363baf8fdba020 zh: d1a6193b1aa813ba /deepseek-ai-dsh-lsp-stdio/use-this-package/observable-success-and-failures: - en: 27766a0ecd0f70fa - zh: 532bc93a8ce5e701 + en: 9b947af73dbe2305 + zh: e40b163f476deaf9 /deepseek-ai-dsh-lsp-stdio/use-this-package/security-boundary: en: f1f7fef771bcaa6d zh: f11dcf771a6fecfc diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index 8f30301f48..418e44520b 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -68,7 +68,7 @@ On the first query for a workspace, the provider launches one server process for ### Observable success and failures -A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. Before the server starts, a missing, non-directory, non-regular, non-UTF-8, or oversized workspace or source fails with its `FsError` code, and a source outside the canonical workspace fails with `LSP_FILE_OUTSIDE_WORKSPACE`. The server then fails the query with `LSP_UNSUPPORTED_OPERATION` when it lacks the operation or the transient open/close synchronization, `LSP_UNSUPPORTED_ENCODING` for a non-UTF-16 position encoding, `LSP_MALFORMED_RESPONSE` for a malformed payload, and `LSP_SERVER_ERROR` for a JSON-RPC error response; a failed process or stream reports `LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`; a query that finds the instance without negotiated capabilities after a successful handshake reports `LSP_NOT_INITIALIZED`, which marks a provider defect. The [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) gives each code's meaning and consumer action. A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. +A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. Before the server starts, a missing, non-directory, non-regular, non-UTF-8, or oversized workspace or source fails with its `FsError` code, and a source outside the canonical workspace fails with `LSP_FILE_OUTSIDE_WORKSPACE`. The server then fails the query with `LSP_UNSUPPORTED_OPERATION` when it lacks the operation or the transient open/close synchronization, `LSP_UNSUPPORTED_ENCODING` for a non-UTF-16 position encoding, `LSP_MALFORMED_RESPONSE` for a malformed payload, and `LSP_SERVER_ERROR` for a JSON-RPC error response; a failed process or stream reports `LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`. The [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) gives each code's meaning and consumer action. A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. ### Security boundary diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index f54eda31c1..e16e912cef 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" ### 可观察的成功与失败 -成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。在服务器启动前,缺失、非目录、非普通文件、非 UTF-8 或过大的工作区或源文件以其 `FsError` 错误码失败,位于规范工作区之外的源文件以 `LSP_FILE_OUTSIDE_WORKSPACE` 失败。随后,服务器不支持该操作或临时打开/关闭同步时查询以 `LSP_UNSUPPORTED_OPERATION` 失败,非 UTF-16 位置编码以 `LSP_UNSUPPORTED_ENCODING` 失败,格式错误的载荷以 `LSP_MALFORMED_RESPONSE` 失败,JSON-RPC 错误响应以 `LSP_SERVER_ERROR` 失败;进程或流失败时报告 `LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`;握手成功后查询却发现实例没有已协商的能力时报告 `LSP_NOT_INITIALIZED`,表示提供方存在缺陷。[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)给出每个错误码的含义与消费方操作。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 +成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。在服务器启动前,缺失、非目录、非普通文件、非 UTF-8 或过大的工作区或源文件以其 `FsError` 错误码失败,位于规范工作区之外的源文件以 `LSP_FILE_OUTSIDE_WORKSPACE` 失败。随后,服务器不支持该操作或临时打开/关闭同步时查询以 `LSP_UNSUPPORTED_OPERATION` 失败,非 UTF-16 位置编码以 `LSP_UNSUPPORTED_ENCODING` 失败,格式错误的载荷以 `LSP_MALFORMED_RESPONSE` 失败,JSON-RPC 错误响应以 `LSP_SERVER_ERROR` 失败;进程或流失败时报告 `LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`。[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)给出每个错误码的含义与消费方操作。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 ### 安全边界 diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 494db76732..4afa5b8d0d 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -32,9 +32,9 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", @@ -46,9 +46,9 @@ "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index eee4962849..7c42effa43 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -119,7 +119,8 @@ function throwTeardownFailures(results: readonly PromiseSettledResult[], m /** * A coded query failure whose instance teardown also failed. It keeps the query failure's `name` - * and `code` so tool results route on the query outcome; `cause` aggregates both failures. + * and `code` so tool results route on the query outcome; `cause` aggregates both failures. It does + * not keep the query failure's class, so route on `name` and `code`, not `instanceof`. */ class LspQueryTeardownError extends HarnessError { constructor(query: HarnessError, teardown: unknown, cause: AggregateError) { diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index f72a892fd5..30c5225468 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -45,7 +45,6 @@ export interface InstanceSpec extends ConnectionSpec { */ export class LspInstance { private readonly connection: LspConnection - private capabilities: WireServerCapabilities | undefined /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */ private queue: Promise = Promise.resolve() private disposed = false @@ -53,8 +52,8 @@ export class LspInstance { private teardownPromise: Promise | undefined /** Set once the process closes, so the pool can synchronously skip a dead instance. */ private processClosed = false - /** Populated once `initialize` succeeds; a failed handshake rejects every query. */ - private readonly ready: Promise + /** The server's negotiated capabilities once `initialize` succeeds; a failed handshake rejects every query. */ + private readonly ready: Promise /** * @param spec - the launch, initialize, and teardown parameters. @@ -109,7 +108,7 @@ export class LspInstance { return run } - private async initialize(): Promise { + private async initialize(): Promise { const initializeResult = await this.connection.request('initialize', { // A subprocess provider may run in another PID namespace or machine; // the host PID would let the server monitor an unrelated process. @@ -122,8 +121,8 @@ export class LspInstance { const capabilities = initializeCapabilities(initializeResult) // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. negotiatePositionEncoding(capabilities.positionEncoding) - this.capabilities = capabilities await this.connection.notify('initialized', {}) + return capabilities } private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { @@ -134,17 +133,15 @@ export class LspInstance { // in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8 // negotiation, malformed result) without the process exiting — tear the instance down so a // permanently-rejecting/pending `ready` can't make every later query for this workspace fail. + let capabilities: WireServerCapabilities try { - await abortable(this.ready, signal) + capabilities = await abortable(this.ready, signal) } catch (error) { if (!this.dead) { await this.awaitTeardownAttempt() } throw error } - const capabilities = this.capabilities - // `ready` resolves only after capabilities are set; reaching this means instance state is inconsistent. - if (capabilities === undefined) throw new LspError('LSP instance is not initialized', 'LSP_NOT_INITIALIZED') if (!supportsOperation(capabilities, request.operation)) { throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION') } diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index 10e0a783b1..e34366042d 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -281,17 +281,6 @@ describe('LspInstance query and abort', () => { }) }) - it('reports inconsistent instance state as LSP_NOT_INITIALIZED', async () => { - const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' }) - Reflect.set(instance, 'capabilities', undefined) - await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ - name: 'LspError', - code: 'LSP_NOT_INITIALIZED', - message: 'LSP instance is not initialized', - }) - }) - it('propagates a server error response even when a signal is supplied (not an abort)', async () => { // A live signal is passed, but the request fails for a server reason; the catch must rethrow // without treating it as an abort. From 646e46e0afeb47bce742a1eae4e473b8d6f51a99 Mon Sep 17 00:00:00 2001 From: dsh-ci-bot Date: Fri, 6 Nov 2026 16:24:29 +0800 Subject: [PATCH 13/81] test(ui-theme): scope the package-scan spec to the client face packageFiles() reports '/'-separated paths on every platform, so the fixture's temporary root must be normalized before it forms the expectation; otherwise the Windows coverage lane fails. The spec also belongs to the client aggregate, because an unsuffixed spec file under packages/client is compiled by both aggregates. --- ...stylesheet-scan.spec.ts => stylesheet-scan.client.spec.ts} | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename packages/client/ui-theme/tests/{stylesheet-scan.spec.ts => stylesheet-scan.client.spec.ts} (82%) diff --git a/packages/client/ui-theme/tests/stylesheet-scan.spec.ts b/packages/client/ui-theme/tests/stylesheet-scan.client.spec.ts similarity index 82% rename from packages/client/ui-theme/tests/stylesheet-scan.spec.ts rename to packages/client/ui-theme/tests/stylesheet-scan.client.spec.ts index 1614ee55a7..312ee01382 100644 --- a/packages/client/ui-theme/tests/stylesheet-scan.spec.ts +++ b/packages/client/ui-theme/tests/stylesheet-scan.client.spec.ts @@ -19,5 +19,7 @@ it('walks shipped package files and skips dot directories holding transient root mkdirSync(join(root, '.typert-worker-failure-probe')) writeFileSync(join(root, '.typert-worker-failure-probe', 'package.json'), '{}') - expect(packageFiles(name => name === 'package.json', root)).toEqual([`${root}/pkg/package.json`]) + // The helper reports `/`-separated paths on every platform, so the Windows + // temporary root's separators must not reach the expectation. + expect(packageFiles(name => name === 'package.json', root)).toEqual([`${root.replaceAll('\\', '/')}/pkg/package.json`]) }) From 4ef46e21b1ca0af4eab649670ffd0929d8124b11 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:41:02 +0800 Subject: [PATCH 14/81] feat(subprocess): add SubprocessError and make Win32Error a HarnessError The subprocess Service Definition exports SubprocessError with a closed SUBPROCESS_* code list, rebases SubprocessExecutableNotFoundError on it with the same name, and adds subprocessSpawnFailure() so consumers that report a spawn rejection classify ENOENT and other platform errors with the original as cause. subprocess-local codes empty argv, Windows Job runner failures, runner IPC failures, and an unloadable node-pty, and names its pre-aborted spawn error AbortError so the tool registry classifies it as cancellation. Win32Error now extends HarnessError with code WIN32_ERROR, keeping its name, api, and win32Code fields. Refs #7869 --- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/index.ts | 19 +++- .../subprocess/subprocess-local/src/spawn.ts | 9 +- .../subprocess-local/src/windows-job.ts | 36 ++++++-- .../subprocess-local/tests/local.spec.ts | 30 +++++++ .../subprocess-local/tests/spawn.spec.ts | 11 +-- .../tests/windows-job.spec.ts | 14 ++- .../subprocess/subprocess/README.i18n.yaml | 8 +- packages/subprocess/subprocess/README.md | 3 +- packages/subprocess/subprocess/README.zh.md | 3 +- packages/subprocess/subprocess/package.json | 6 +- packages/subprocess/subprocess/src/error.ts | 87 +++++++++++++++++++ packages/subprocess/subprocess/src/index.ts | 19 ++-- .../subprocess/subprocess/tests/error.spec.ts | 48 ++++++++++ packages/subprocess/subprocess/tsconfig.json | 3 + .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 2 +- .../subprocess/win32-process/README.zh.md | 2 +- .../subprocess/win32-process/package.json | 6 +- .../subprocess/win32-process/src/errors.ts | 14 ++- .../win32-process/tests/ffi.spec.ts | 5 ++ .../subprocess/win32-process/tsconfig.json | 3 + pnpm-lock.yaml | 6 ++ 25 files changed, 292 insertions(+), 54 deletions(-) create mode 100644 packages/subprocess/subprocess/src/error.ts create mode 100644 packages/subprocess/subprocess/tests/error.spec.ts diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index e46cbba8b2..b81bef5b96 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -36,8 +36,8 @@ en: cd0b8a5dc61335a5 zh: ac5077a9ac277141 /deepseek-ai-dsh-subprocess-local/use-this-package/what-can-go-wrong: - en: 1e1ce532b004c92b - zh: ca7274b7a9207a5d + en: 8320b322423ed5a4 + zh: a32e769d2a351694 /deepseek-ai-dsh-subprocess-local/understand-the-implementation: en: 531a48766c666ea0 zh: b40df533a2fd7244 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 6d943b32e6..6ede37c8d1 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -69,7 +69,7 @@ Linux ordinary and terminal cancellation preserves the observed termination sign ### What can go wrong -An executable that cannot be resolved fails loud with a stable error. `done` rejects when spawn or provider failure prevents a direct outcome, and that rejection does not prove whether target execution began. `waitForExit()` rejects if the selected owner can no longer prove its range empty, and cleanup still attempts termination. A read past the retained tail is `lossy` and points at the spill file when one exists. A fallback process group or observed terminal session can miss a descendant that escapes before observation — see the limitations below. +An executable that cannot be resolved fails loud with a stable error. `done` rejects when spawn or provider failure prevents a direct outcome, and that rejection does not prove whether target execution began: a platform spawn error stays the original Node error, while a Windows Job runner that fails after it started or breaks its IPC channel rejects with `SubprocessError` `SUBPROCESS_RUNNER_FAILED` or `SUBPROCESS_RUNNER_IPC_FAILED`. A terminal request on a host where node-pty cannot load throws `SUBPROCESS_PTY_UNAVAILABLE`. `waitForExit()` rejects if the selected owner can no longer prove its range empty, and cleanup still attempts termination. A read past the retained tail is `lossy` and points at the spill file when one exists. A fallback process group or observed terminal session can miss a descendant that escapes before observation — see the limitations below. ----- diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index d82d971cdf..0240ea3c50 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -69,7 +69,7 @@ Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取 ### 可能出错的地方 -无法解析的可执行文件会明确报出稳定错误。当 spawn 或提供方故障使 direct outcome 无法产生时,`done` 会 reject;该 rejection 不能证明 target 是否已经开始执行。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。 +无法解析的可执行文件会明确报出稳定错误。当 spawn 或提供方故障使 direct outcome 无法产生时,`done` 会 reject;该 rejection 不能证明 target 是否已经开始执行:平台 spawn 错误保持为原始 Node 错误,而 Windows Job runner 启动后失败或其 IPC 通道损坏时以 `SubprocessError` 的 `SUBPROCESS_RUNNER_FAILED` 或 `SUBPROCESS_RUNNER_IPC_FAILED` reject。在无法加载 node-pty 的主机上请求终端会抛出 `SUBPROCESS_PTY_UNAVAILABLE`。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。 ----- diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index be24927093..e9960d0f0b 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -17,7 +17,7 @@ import { Context } from '@deepseek-ai/cordis' import type * as NodePty from 'node-pty' import type { IPtyForkOptions, IWindowsPtyForkOptions } from 'node-pty' import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require' -import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessError, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec, @@ -54,6 +54,19 @@ import { prepareShellActivity } from './shell-activity.ts' const requireNodePty = createLazyRequire('node-pty', import.meta.url) +/** Load node-pty, reporting a missing or unloadable native module as `SUBPROCESS_PTY_UNAVAILABLE`. */ +function loadNodePty(): typeof NodePty { + try { + return requireNodePty() + } catch (error) { + throw new SubprocessError( + `subprocess-local: terminals are unavailable because node-pty cannot be loaded: ${String(error)}`, + 'SUBPROCESS_PTY_UNAVAILABLE', + { cause: error }, + ) + } +} + /** * Local subprocess service: platform-selected managed ranges, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill @@ -290,7 +303,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise { const file = spec.argv[0] if (file === undefined || file.length === 0) { - throw new Error('subprocess-local: terminal argv must contain a program') + throw new SubprocessError('subprocess-local: terminal argv must contain a program', 'SUBPROCESS_INVALID_ARGV') } spec.signal?.throwIfAborted() const inspector = this.terminalInspector ?? createProcessInspector() @@ -319,7 +332,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { ? undefined : prepareLinuxTerminalScope(launch, { ...activity?.env ?? env, PWD: spec.cwd, TERM: spec.terminalType }, linuxScope.literalArgv) if (scope !== undefined) { options.cwd = scope.cwd; options.env = scope.env } - terminal = requireNodePty().spawn( + terminal = loadNodePty().spawn( scope?.command ?? file, scope?.args ?? [...launch.argv.slice(1)], options, diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8da083e6f5..e3a47ee818 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -11,7 +11,7 @@ import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from 'node:child_process' import type { Readable } from 'node:stream' import { setTimeout as sleepMs } from 'node:timers/promises' -import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv, SubprocessError } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessCollect, @@ -172,11 +172,14 @@ export function validateSubprocessSpec(spec: SubprocessSpawnSpec): void { } catch { // Arbitrary caller-owned reasons cannot escape the stable Error boundary. } - throw new Error(`aborted before spawn: ${reason}`) + // Named as a cancellation so the tool registry classifies it as the caller's abort. + const error = new Error(`aborted before spawn: ${reason}`) + error.name = 'AbortError' + throw error } const [program] = spec.argv if (program === undefined || program.length === 0) { - throw new Error('invalid argv: expected a non-empty program name at argv[0]') + throw new SubprocessError('invalid argv: expected a non-empty program name at argv[0]', 'SUBPROCESS_INVALID_ARGV') } } diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 57f7c46afc..34bfff74d5 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -5,6 +5,7 @@ import { spawn } from 'node:child_process' import { closeSync, openSync } from 'node:fs' import { devNull } from 'node:os' import type { Readable, Writable } from 'node:stream' +import { SubprocessError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { loadWin32ProcessBindings, @@ -26,6 +27,16 @@ import { } from './runner-launch.ts' import type { RunnerInvocation } from './runner-launch.ts' +/** Classify a failure on the private runner channel, keeping an already coded failure. */ +function runnerIpcFailure(error: unknown): SubprocessError { + if (error instanceof SubprocessError) return error + return new SubprocessError( + `subprocess-local: Windows runner IPC failed: ${String(error)}`, + 'SUBPROCESS_RUNNER_IPC_FAILED', + { cause: error }, + ) +} + /** Test seams for runner launch and dynamic capability checks. */ export interface WindowsJobInternals { spawn?: typeof spawn @@ -171,8 +182,9 @@ export function launchWindowsJob( const status = signal !== null ? `signal ${signal}` : exitCode === null ? 'without an exit status' : `exit code ${String(exitCode)}` - failInfrastructure(new Error( + failInfrastructure(new SubprocessError( `subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`, + 'SUBPROCESS_RUNNER_FAILED', )) } @@ -184,7 +196,7 @@ export function launchWindowsJob( ) child.on('message', (value: unknown) => { if (directResultType !== undefined) { - const error = new Error('subprocess-local: Windows runner emitted more than one direct result') + const error = new SubprocessError('subprocess-local: Windows runner emitted more than one direct result', 'SUBPROCESS_RUNNER_IPC_FAILED') failInfrastructure(error) owner.terminateForHostExit() return @@ -193,7 +205,11 @@ export function launchWindowsJob( try { result = parseWindowsRunnerResult(value) } catch (error) { - failInfrastructure(error) + failInfrastructure(new SubprocessError( + `subprocess-local: Windows runner emitted a malformed result: ${String(error)}`, + 'SUBPROCESS_RUNNER_IPC_FAILED', + { cause: error }, + )) owner.terminateForHostExit() return } @@ -208,14 +224,16 @@ export function launchWindowsJob( child.once('spawn', () => { runnerSpawned = true try { - if (child.send === undefined) throw new Error('subprocess-local: Windows runner has no IPC channel') + if (child.send === undefined) { + throw new SubprocessError('subprocess-local: Windows runner has no IPC channel', 'SUBPROCESS_RUNNER_IPC_FAILED') + } child.send({ type: 'start', cwd: spec.cwd, env: targetEnv, ...spec.stdio.control === undefined ? {} : { control: spec.stdio.control } }, (error) => { if (error === null) return - failInfrastructure(error) + failInfrastructure(runnerIpcFailure(error)) owner.terminateForHostExit() }) } catch (error) { - failInfrastructure(error) + failInfrastructure(runnerIpcFailure(error)) owner.terminateForHostExit() } }) @@ -225,7 +243,11 @@ export function launchWindowsJob( rangeExit.resolve() return } - failInfrastructure(error) + failInfrastructure(new SubprocessError( + `subprocess-local: Windows Job runner failed after it started: ${String(error)}`, + 'SUBPROCESS_RUNNER_FAILED', + { cause: error }, + )) }) child.once('exit', (exitCode, signal) => { runnerExit = { exitCode, signal } diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 2c4c0b2f67..3229c0837f 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -490,6 +490,36 @@ describe('LocalSubprocessRuntime', () => { } }) + it('reports an unloadable node-pty as SUBPROCESS_PTY_UNAVAILABLE', async () => { + const loadFailure = new Error('Cannot find module node-pty') + vi.resetModules() + mockWin32ForIsolatedRuntime() + vi.doMock('@deepseek-ai/dsh-lazy-require', () => ({ + createLazyRequire: () => () => { throw loadFailure }, + })) + vi.doMock('../src/linux-scope.ts', async importOriginal => ({ + ...await importOriginal(), + probeLinuxNative: () => undefined, + })) + try { + const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts') + const ctx = new Context() + const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) + await expect(ctx.subprocess.spawnTerminal({ + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, + })).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_PTY_UNAVAILABLE', cause: loadFailure }) + await expect(ctx.subprocess.spawnTerminal({ + argv: [''], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, + })).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_INVALID_ARGV' }) + await fiber.dispose() + } finally { + unmockLazyRequireForIsolatedRuntime() + vi.doUnmock('../src/linux-scope.ts') + unmockWin32ForIsolatedRuntime() + vi.resetModules() + } + }) + it('allocates Windows terminals through the console host node-pty ships', async () => { const exitListeners: ((event: { exitCode: number; signal?: number }) => void)[] = [] const terminal = { diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 27e5ab93ac..1ba8c38dc5 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -352,7 +352,7 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) - it('throws a stable Error when already aborted before spawn', () => { + it('throws a stable AbortError when already aborted before spawn', () => { for (const [reason, message] of [ ['too late', 'aborted before spawn: too late'], [null, 'aborted before spawn: aborted'], @@ -360,7 +360,7 @@ describe('spawnSubprocess', () => { const controller = new AbortController() controller.abort(reason) expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) - .toThrow(new Error(message)) + .toThrow(expect.objectContaining({ name: 'AbortError', message })) } const controller = new AbortController() @@ -370,7 +370,7 @@ describe('spawnSubprocess', () => { }, }) expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) - .toThrow(new Error('aborted before spawn: aborted')) + .toThrow(expect.objectContaining({ name: 'AbortError', message: 'aborted before spawn: aborted' })) }) it('rejects with a spawn error for a nonexistent cwd', async () => { @@ -1446,7 +1446,8 @@ describe('coverage seams 2', () => { describe('argv validation', () => { it('rejects an empty argv before spawning', () => { - expect(() => { validateSubprocessSpec({ ...spec('true'), argv: [] }) }).toThrow(/non-empty program name/) + expect(() => { validateSubprocessSpec({ ...spec('true'), argv: [] }) }) + .toThrow(expect.objectContaining({ name: 'SubprocessError', code: 'SUBPROCESS_INVALID_ARGV' })) }) it('rejects an empty program name before spawning', () => { @@ -1468,7 +1469,7 @@ describe('abort edge cases', () => { removeEventListener() {}, } as unknown as AbortSignal expect(() => { validateSubprocessSpec(spec('echo hi', { signal: bare })) }) - .toThrow(new Error('aborted before spawn: aborted')) + .toThrow(expect.objectContaining({ name: 'AbortError', message: 'aborted before spawn: aborted' })) }) it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index c2f30a682b..869f7b89b0 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -350,6 +350,7 @@ describe('Windows parent runner contract', () => { statusless.child.connected = false statusless.child.exit( null, null) await expect(statusless.result.direct).rejects.toThrow('without an exit status') + await expect(statusless.result.direct).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_FAILED' }) }) it('fails closed on malformed/duplicate result, runner spawn error, and start-send error', async () => { @@ -357,6 +358,7 @@ describe('Windows parent runner contract', () => { malformed.child.emit('message', { type: 'target-exit', exitCode: -1 }) expect(malformed.child.killed).toEqual(['SIGKILL']) await expect(malformed.result.direct).rejects.toThrow('invalid target-exit') + await expect(malformed.result.direct).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_IPC_FAILED' }) await expect(malformed.result.owner.waitForExit()).rejects.toThrow('invalid target-exit') const duplicate = launch() @@ -364,6 +366,7 @@ describe('Windows parent runner contract', () => { duplicate.child.emit('message', { type: 'target-exit', exitCode: 0 }) await expect(duplicate.result.direct).resolves.toEqual({ exitCode: 0, signal: null }) await expect(duplicate.result.owner.waitForExit()).rejects.toThrow('more than one direct result') + await expect(duplicate.result.owner.waitForExit()).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_IPC_FAILED' }) const errored = launch(new FakeChild(), spec, false) const spawnError = new Error('runner executable missing') @@ -375,8 +378,9 @@ describe('Windows parent runner contract', () => { const postSpawnError = launch() const infrastructureError = new Error('runner failed after spawn') postSpawnError.child.emit('error', infrastructureError) - await expect(postSpawnError.result.direct).rejects.toBe(infrastructureError) - await expect(postSpawnError.result.owner.waitForExit()).rejects.toBe(infrastructureError) + const runnerFailure = { name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_FAILED', cause: infrastructureError } + await expect(postSpawnError.result.direct).rejects.toMatchObject(runnerFailure) + await expect(postSpawnError.result.owner.waitForExit()).rejects.toMatchObject(runnerFailure) const sendFailedChild = new FakeChild() sendFailedChild.sendError = new Error('IPC send failed') @@ -389,14 +393,16 @@ describe('Windows parent runner contract', () => { Object.defineProperty(noIpc, 'send', { value: undefined }) const noIpcResult = launch(noIpc).result await expect(noIpcResult.direct).rejects.toThrow('has no IPC channel') + await expect(noIpcResult.direct).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_IPC_FAILED' }) await expect(noIpcResult.owner.waitForExit()).rejects.toThrow('has no IPC channel') const nonError = new FakeChild() nonError.throwOnSendCall = 1 nonError.sendThrown = 'start send failed' const nonErrorResult = launch(nonError).result - await expect(nonErrorResult.direct).rejects.toBe('start send failed') - await expect(nonErrorResult.owner.waitForExit()).rejects.toBe('start send failed') + const ipcFailure = { name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_IPC_FAILED', cause: 'start send failed' } + await expect(nonErrorResult.direct).rejects.toMatchObject(ipcFailure) + await expect(nonErrorResult.owner.waitForExit()).rejects.toMatchObject(ipcFailure) }) it('fails infrastructure and kills the runner when termination delivery fails', async () => { diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index f76390eb90..762893c5a0 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -39,8 +39,8 @@ en: 4d4cbb311575d071 zh: 7be2d265adb661bc /deepseek-ai-dsh-subprocess/use-this-package/what-can-go-wrong: - en: 51f2f43087a1ed18 - zh: 6d2ebb099b707df3 + en: 718494d6530da700 + zh: a41b7d48a9d12178 /deepseek-ai-dsh-subprocess/understand-the-implementation: en: 25cd344c959cfd9e zh: 045880776ab82d1e @@ -48,8 +48,8 @@ en: 07201ec2256c25f6 zh: 046109cfa92194f3 /deepseek-ai-dsh-subprocess/understand-the-implementation/source-map: - en: 85e3d2109f49356d - zh: 071ccfef37e8e33f + en: e56d6825d1b990a5 + zh: c70cabda3bfcd2ab /deepseek-ai-dsh-subprocess/understand-the-implementation/data-model-and-flow: en: 9998dc0a990fbcf1 zh: ba179617f60cb9bc diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 4839a508e5..337afd9fba 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -81,7 +81,7 @@ Children never inherit the harness's ambient secrets: credential-shaped names an ### What can go wrong -An executable that cannot be resolved fails loudly with a stable error. A spawn that never starts rejects `done`; there is no buffered output for a process that never ran. `waitForExit()` also rejects when the provider cannot prove its selected range is empty, and a provider fallback may not own descendants that escape its process group or observed session. When a transport owns its own spawn (the SDK client, MCP), route around the service and import `scrubbedParentEnv` directly so environment policy stays single-sourced. +Coded failures are `SubprocessError` instances whose `code` is one of `SUBPROCESS_ERROR_CODES`: an empty argv is `SUBPROCESS_INVALID_ARGV`, an executable lookup miss is `SubprocessExecutableNotFoundError` (`SUBPROCESS_EXECUTABLE_NOT_FOUND`), a provider's private runner that fails or breaks its channel is `SUBPROCESS_RUNNER_FAILED` or `SUBPROCESS_RUNNER_IPC_FAILED`, and an unloadable terminal implementation is `SUBPROCESS_PTY_UNAVAILABLE`. A spawn requested on an already aborted signal throws an error named `AbortError`. A spawn that never starts rejects `done` with the platform's original error, so consumers can inspect its errno fields; a consumer that reports the rejection as its own failure converts it with `subprocessSpawnFailure()`, which yields `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` otherwise. There is no buffered output for a process that never ran. `waitForExit()` also rejects when the provider cannot prove its selected range is empty, and a provider fallback may not own descendants that escape its process group or observed session. When a transport owns its own spawn (the SDK client, MCP), route around the service and import `scrubbedParentEnv` directly so environment policy stays single-sourced. ----- @@ -103,6 +103,7 @@ The seam is built on one separation: the service owns process coordinates and li |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `SubprocessRuntime`, `ctx.subprocess` registration, the shared `scrubbedParentEnv` scrub | | [`src/types.ts`](src/types.ts) | Vocabulary: spawn spec, stdio modes, handles, readers, outcomes, `DSH_*` namespace | +| [`src/error.ts`](src/error.ts) | `SubprocessError`, its closed code list, and spawn-rejection classification | ### Data model and flow diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 843dcdb568..da111f517a 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -81,7 +81,7 @@ const output = handle.collected.stdout?.readFrom(0) ### 可能出错的地方 -无法解析可执行文件时,服务会明确报出稳定的错误。从未启动成功的 spawn 会让 `done` reject;从未运行过的进程没有任何缓冲输出。提供方无法证明所选范围为空时,`waitForExit()` 也会 reject;提供方 fallback 可能无法拥有逃离其进程组或已观察会话的后代。当传输拥有自己的 spawn(SDK 客户端、MCP)时,请绕开本服务并直接导入 `scrubbedParentEnv`,让环境策略保持单一来源。 +带 code 的失败都是 `SubprocessError`,其 `code` 属于 `SUBPROCESS_ERROR_CODES`:argv 为空是 `SUBPROCESS_INVALID_ARGV`,可执行文件查找未命中是 `SubprocessExecutableNotFoundError`(`SUBPROCESS_EXECUTABLE_NOT_FOUND`),提供方私有 runner 失败或通道损坏是 `SUBPROCESS_RUNNER_FAILED` 或 `SUBPROCESS_RUNNER_IPC_FAILED`,终端实现无法加载是 `SUBPROCESS_PTY_UNAVAILABLE`。在已 abort 的 signal 上请求 spawn 会抛出名为 `AbortError` 的错误。从未启动成功的 spawn 会让 `done` 以平台原始错误 reject,便于消费者检查 errno 字段;把该 reject 报告为自身失败的消费者用 `subprocessSpawnFailure()` 转换,`ENOENT` 得到 `SUBPROCESS_SPAWN_NOT_FOUND`,其余得到 `SUBPROCESS_SPAWN_FAILED`。从未运行过的进程没有任何缓冲输出。提供方无法证明所选范围为空时,`waitForExit()` 也会 reject;提供方 fallback 可能无法拥有逃离其进程组或已观察会话的后代。当传输拥有自己的 spawn(SDK 客户端、MCP)时,请绕开本服务并直接导入 `scrubbedParentEnv`,让环境策略保持单一来源。 ----- @@ -103,6 +103,7 @@ const output = handle.collected.stdout?.readFrom(0) |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `SubprocessRuntime`、`ctx.subprocess` 注册、共享的 `scrubbedParentEnv` 清除 | | [`src/types.ts`](src/types.ts) | 词汇:spawn spec、stdio 模式、句柄、读取器、结果、`DSH_*` 命名空间 | +| [`src/error.ts`](src/error.ts) | `SubprocessError`、其封闭 code 列表与 spawn reject 分类 | ### 数据模型与流程 diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 9fb7353455..4d4968fbfb 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -36,11 +36,13 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-http-proxy": "workspace:*" + "@deepseek-ai/dsh-http-proxy": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-http-proxy": "workspace:*", - "@deepseek-ai/dsh-launch-environment": "workspace:*" + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*" } } diff --git a/packages/subprocess/subprocess/src/error.ts b/packages/subprocess/subprocess/src/error.ts new file mode 100644 index 0000000000..064dc494ae --- /dev/null +++ b/packages/subprocess/subprocess/src/error.ts @@ -0,0 +1,87 @@ +/** Machine-routable subprocess failures shared by every `ctx.subprocess` provider and its consumers. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Every {@link SubprocessError} code: + * - `SUBPROCESS_INVALID_ARGV` — the request names no program; the caller fixes its argv. + * - `SUBPROCESS_EXECUTABLE_NOT_FOUND` — executable lookup found no executable file. + * - `SUBPROCESS_SPAWN_NOT_FOUND` — the platform reported `ENOENT` for the program or the working directory. + * - `SUBPROCESS_SPAWN_FAILED` — the platform refused the spawn for another reason; `cause` holds the original error. + * - `SUBPROCESS_RUNNER_FAILED` — the provider's private runner exited or reported a result before proving the process range empty. + * - `SUBPROCESS_RUNNER_IPC_FAILED` — the provider's private runner channel was missing or carried a malformed message. + * - `SUBPROCESS_PTY_UNAVAILABLE` — the host cannot load its terminal implementation. + */ +export const SUBPROCESS_ERROR_CODES = [ + 'SUBPROCESS_INVALID_ARGV', + 'SUBPROCESS_EXECUTABLE_NOT_FOUND', + 'SUBPROCESS_SPAWN_NOT_FOUND', + 'SUBPROCESS_SPAWN_FAILED', + 'SUBPROCESS_RUNNER_FAILED', + 'SUBPROCESS_RUNNER_IPC_FAILED', + 'SUBPROCESS_PTY_UNAVAILABLE', +] as const + +/** One code from {@link SUBPROCESS_ERROR_CODES}. */ +export type SubprocessErrorCode = typeof SUBPROCESS_ERROR_CODES[number] + +/** Subprocess failure carrying a stable {@link SubprocessErrorCode}. */ +export class SubprocessError extends HarnessError { + override readonly code: SubprocessErrorCode + + /** + * @param message - names the process operation and the safe next step. + * @param code - the failure class consumers route on. + * @param options - standard `cause` chaining; foreign errno and Win32 codes stay there and in `message`. + */ + constructor(message: string, code: SubprocessErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SubprocessError' + this.code = code + } +} + +/** Executable lookup completed without finding an executable file; `code` is `SUBPROCESS_EXECUTABLE_NOT_FOUND`. */ +export class SubprocessExecutableNotFoundError extends SubprocessError { + /** + * @param message - provider-specific lookup diagnostic. + * @param options - original provider failure, when available. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, 'SUBPROCESS_EXECUTABLE_NOT_FOUND', options) + this.name = 'SubprocessExecutableNotFoundError' + } +} + +/** + * Classify a spawn rejection for a consumer that reports it as its own + * failure. Providers reject with the platform's original error so that + * consumers can inspect errno fields; the consumer that turns the rejection + * into a tool or service failure converts it here and keeps the original as + * `cause`. + * @param error - the provider's rejection value. + * @param program - the program the consumer asked to spawn, named in the message. + * @returns `error` unchanged when it is a `HarnessError` or a cancellation + * (`name === 'AbortError'`); otherwise a {@link SubprocessError} with + * `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` for + * any other value. + */ +export function subprocessSpawnFailure(error: unknown, program: string): unknown { + if (error instanceof HarnessError) return error + if (error instanceof Error && error.name === 'AbortError') return error + const errno = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined + let detail = 'unprintable failure' + try { + detail = error instanceof Error ? error.message : String(error) + } catch { + // A provider-owned rejection whose conversion throws still yields a coded failure. + } + if (errno === 'ENOENT') { + return new SubprocessError( + `cannot start ${JSON.stringify(program)}: the program or the working directory does not exist (${detail})`, + 'SUBPROCESS_SPAWN_NOT_FOUND', + { cause: error }, + ) + } + return new SubprocessError(`cannot start ${JSON.stringify(program)}: ${detail}`, 'SUBPROCESS_SPAWN_FAILED', { cause: error }) +} diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 6dbbc62719..b134ffb6c1 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -15,6 +15,13 @@ import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' import type { SubprocessTerminalEnvironment, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' +export { + SUBPROCESS_ERROR_CODES, + SubprocessError, + SubprocessExecutableNotFoundError, + subprocessSpawnFailure, + type SubprocessErrorCode, +} from './error.ts' export type { CollectedOutput, DshEnvironment, @@ -163,15 +170,3 @@ export abstract class SubprocessRuntime extends Service { } export default SubprocessRuntime - -/** Executable lookup completed without finding an executable file. */ -export class SubprocessExecutableNotFoundError extends Error { - /** - * @param message - provider-specific lookup diagnostic. - * @param options - original provider failure, when available. - */ - constructor(message: string, options?: ErrorOptions) { - super(message, options) - this.name = 'SubprocessExecutableNotFoundError' - } -} diff --git a/packages/subprocess/subprocess/tests/error.spec.ts b/packages/subprocess/subprocess/tests/error.spec.ts new file mode 100644 index 0000000000..ecdcb89a98 --- /dev/null +++ b/packages/subprocess/subprocess/tests/error.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { + SUBPROCESS_ERROR_CODES, + SubprocessError, + SubprocessExecutableNotFoundError, + subprocessSpawnFailure, +} from '../src/index.ts' + +describe('SubprocessError', () => { + it('carries a literal name and a code from the closed list', () => { + const cause = new Error('low level') + const error = new SubprocessError('runner failed', 'SUBPROCESS_RUNNER_FAILED', { cause }) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_RUNNER_FAILED', cause }) + expect(SUBPROCESS_ERROR_CODES).toContain(error.code) + }) + + it('keeps the executable lookup miss as its own subclass', () => { + const error = new SubprocessExecutableNotFoundError('not on PATH') + expect(error).toBeInstanceOf(SubprocessError) + expect(error).toMatchObject({ name: 'SubprocessExecutableNotFoundError', code: 'SUBPROCESS_EXECUTABLE_NOT_FOUND' }) + }) +}) + +describe('subprocessSpawnFailure', () => { + it('classifies ENOENT and other spawn rejections, keeping the original as cause', () => { + const missing = Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT', syscall: 'spawn pwsh' }) + expect(subprocessSpawnFailure(missing, 'pwsh')).toMatchObject({ + name: 'SubprocessError', + code: 'SUBPROCESS_SPAWN_NOT_FOUND', + cause: missing, + message: 'cannot start "pwsh": the program or the working directory does not exist (spawn pwsh ENOENT)', + }) + const denied = Object.assign(new Error('spawn bash EACCES'), { code: 'EACCES' }) + expect(subprocessSpawnFailure(denied, 'bash')).toMatchObject({ code: 'SUBPROCESS_SPAWN_FAILED', cause: denied }) + expect(subprocessSpawnFailure('raw', 'bash')).toMatchObject({ code: 'SUBPROCESS_SPAWN_FAILED', message: 'cannot start "bash": raw' }) + const unprintable = { [Symbol.toPrimitive]() { throw new Error('no') } } + expect(subprocessSpawnFailure(unprintable, 'bash')).toMatchObject({ message: 'cannot start "bash": unprintable failure' }) + }) + + it('returns coded errors and cancellations unchanged', () => { + const coded = new HarnessError('sandbox unavailable', 'SANDBOX_UNAVAILABLE') + expect(subprocessSpawnFailure(coded, 'bash')).toBe(coded) + const abort = new DOMException('aborted', 'AbortError') + expect(subprocessSpawnFailure(abort, 'bash')).toBe(abort) + }) +}) diff --git a/packages/subprocess/subprocess/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json index e72e6a3dd7..a39b295358 100644 --- a/packages/subprocess/subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../util/launch-environment" + }, + { + "path": "../../llm/llm" } ] } diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 6cfa81bc6d..8c9fb0557c 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -15,8 +15,8 @@ en: a3450c8cdd5f5802 zh: 15eec66f42e42ce7 /deepseek-ai-dsh-win32-process/behavior: - en: 77806095cd7f781d - zh: 69d09cabc4d49185 + en: 9b81bbd4b1a16ddc + zh: b07fc32b41c3cc66 /deepseek-ai-dsh-win32-process/header-verification: en: 2b60a9001b480546 zh: 12a621e421c7d789 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index af7cc79503..23ce6f94dd 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -24,7 +24,7 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. A failed checked call throws `Win32Error`, a `HarnessError` with code `WIN32_ERROR` whose `api` and `win32Code` fields name the call; callers that own the failing operation wrap it in their own coded error with the `Win32Error` as `cause`. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the restricted-token null-environment policy, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 7c5685b77a..c3df8aa576 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -24,7 +24,7 @@ kind: "package-library" ## 行为 -- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让沙箱策略通过同一组已加载库绑定剩余 API。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让沙箱策略通过同一组已加载库绑定剩余 API。受检调用失败时抛出 `Win32Error`,它是 code 为 `WIN32_ERROR` 的 `HarnessError`,`api` 与 `win32Code` 字段标明失败的调用;拥有该操作的调用方再把它包装为自己的带 code 错误,并以 `Win32Error` 作为 `cause`。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求沙箱的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引号处理、cwd、restricted-token null 环境策略、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 7f3d76d56f..7eae3438fc 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -30,13 +30,15 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-lazy-require": "workspace:*", "koffi": "3.1.1" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*" } } diff --git a/packages/subprocess/win32-process/src/errors.ts b/packages/subprocess/win32-process/src/errors.ts index f6e90805a1..d8cd4caa86 100644 --- a/packages/subprocess/win32-process/src/errors.ts +++ b/packages/subprocess/win32-process/src/errors.ts @@ -1,12 +1,22 @@ +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** Code of every {@link Win32Error}; the failing API and its numeric code stay in `api`, `win32Code`, and `message`. */ +export const WIN32_ERROR = 'WIN32_ERROR' + /** Win32 call failure with the exact API name and error code. */ -export class Win32Error extends Error { +export class Win32Error extends HarnessError { /** Win32 function whose checked result failed. */ readonly api: string /** Exact GetLastError value or direct Win32 API error code. */ readonly win32Code: number + /** + * @param api - Win32 function whose checked result failed. + * @param win32Code - exact GetLastError value or direct API error code. + * @param detail - optional subject, such as the path the call targeted. + */ constructor(api: string, win32Code: number, detail?: string) { - super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) + super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`, WIN32_ERROR) this.name = 'Win32Error' this.api = api this.win32Code = win32Code diff --git a/packages/subprocess/win32-process/tests/ffi.spec.ts b/packages/subprocess/win32-process/tests/ffi.spec.ts index efb4db205a..a202503369 100644 --- a/packages/subprocess/win32-process/tests/ffi.spec.ts +++ b/packages/subprocess/win32-process/tests/ffi.spec.ts @@ -1,6 +1,8 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' +import { HarnessError } from '@deepseek-ai/dsh-llm' import { + WIN32_ERROR, Win32Error, allocPtrSlot, decodePtr, @@ -35,6 +37,9 @@ describe('shared Win32 process ABI', () => { expect(errorText(api, 5)).toBe('access denied') expect(() => throwLastError(api, 'Probe')).toThrow(Win32Error) expect(new Win32Error('CloseHandle', 6).message).toBe('CloseHandle failed (Win32 6)') + const error = new Win32Error('CloseHandle', 6) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'Win32Error', code: WIN32_ERROR, api: 'CloseHandle', win32Code: 6 }) }) it('decodes a pointer stored by Koffi', () => { diff --git a/packages/subprocess/win32-process/tsconfig.json b/packages/subprocess/win32-process/tsconfig.json index 93abec7c46..ac86e810e8 100644 --- a/packages/subprocess/win32-process/tsconfig.json +++ b/packages/subprocess/win32-process/tsconfig.json @@ -11,6 +11,9 @@ }, { "path": "../../util/lazy-require" + }, + { + "path": "../../llm/llm" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca94685474..1884575900 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16984,6 +16984,9 @@ importers: '@deepseek-ai/dsh-launch-environment': specifier: workspace:* version: link:../../util/launch-environment + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm packages/subprocess/subprocess-local: dependencies: @@ -17025,6 +17028,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm packages/team/agent-team: dependencies: From 9626ccfae8c1d0ac626427d86823a42b48ad3381 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:51:19 +0800 Subject: [PATCH 15/81] feat(sandbox): code escalation outcomes and Windows ACL preparation failures The sandbox Service Definition adds SandboxEscalationError with SANDBOX_ESCALATION_INVALID_ARGUMENT, _UNAVAILABLE, _REJECTED, and _CANCELLED, thrown by validateEscalationArgs() and approveEscalation() with their existing messages. A cancelled approval for an aborted call rethrows the call's cancellation instead. It also adds SandboxPreparationError. sandbox-local wraps Windows ACL SID, grant, temp-directory, and path-resolution failures as SANDBOX_PREPARATION_FAILED with the Win32Error or Node error (or an AggregateError with cleanup failures) as cause, and reports a temp root inside the workspace as SANDBOX_INVALID_TEMP_ROOT. sandbox-windows-acl exports isTempRootInsideWorkspace() for that check. Refs #7869 --- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 2 +- docs/subsystems/sandbox.zh.md | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- packages/sandbox/sandbox-local/src/index.ts | 65 ++++++++++++--- .../sandbox-local/tests/acl-grants.spec.ts | 83 ++++++++++++++++--- .../sandbox/sandbox-windows-acl/src/index.ts | 2 +- .../sandbox-windows-acl/src/path-boundary.ts | 15 +++- packages/sandbox/sandbox/README.i18n.yaml | 8 +- packages/sandbox/sandbox/README.md | 4 +- packages/sandbox/sandbox/README.zh.md | 4 +- packages/sandbox/sandbox/src/escalation.ts | 77 ++++++++++++++--- packages/sandbox/sandbox/src/index.ts | 42 +++++++++- .../sandbox/sandbox/tests/escalation.spec.ts | 42 +++++++++- .../sandbox/sandbox/tests/vocabulary.spec.ts | 13 ++- 17 files changed, 317 insertions(+), 54 deletions(-) diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 3d2954b1c4..6f92d1861f 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -15,5 +15,5 @@ en: 2ba8930762e92e40 zh: 220f64da1a9e5d57 /process-sandbox/provider-and-fail-closed-errors: - en: a90a38fe4b11737c - zh: 2397768e48712617 + en: a1f9649b3cde0aca + zh: 6708348a70eb2451 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index c530276e90..e5c3021503 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -161,7 +161,7 @@ The [local provider](../../packages/sandbox/sandbox-local/README.md) owns operat ## Provider and fail-closed errors -`await ctx.sandbox.confine(argv, policy, signal)` resolves policy paths and returns a `ConfinedArgv` from the execution world, or rejects with `SandboxUnavailableError` and code `SANDBOX_UNAVAILABLE` when no usable backend exists. The optional signal cancels resolution before launch. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy. +`await ctx.sandbox.confine(argv, policy, signal)` resolves policy paths and returns a `ConfinedArgv` from the execution world, or rejects with `SandboxUnavailableError` and code `SANDBOX_UNAVAILABLE` when no usable backend exists, or with `SandboxPreparationError` (`SANDBOX_PREPARATION_FAILED`, `SANDBOX_INVALID_TEMP_ROOT`) when the provider cannot prepare host state for the mode. The optional signal cancels resolution before launch. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy. Provider selection, probing, caching, and backend-specific enforcement reports belong to the [local provider](../../packages/sandbox/sandbox-local/README.md). diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index c3cfd0720e..10093b09e2 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -161,7 +161,7 @@ interface ConfinedArgv { ## 提供方与 fail-closed 错误 -`await ctx.sandbox.confine(argv, policy, signal)` 在执行环境中解析策略路径并返回 `ConfinedArgv`,没有可用后端时以 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)拒绝。可选信号可在启动前取消解析。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方约定。对于受限策略,静默的无隔离透传永远不合法。 +`await ctx.sandbox.confine(argv, policy, signal)` 在执行环境中解析策略路径并返回 `ConfinedArgv`,没有可用后端时以 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)拒绝,提供方无法为该模式准备主机状态时以 `SandboxPreparationError`(`SANDBOX_PREPARATION_FAILED`、`SANDBOX_INVALID_TEMP_ROOT`)拒绝。可选信号可在启动前取消解析。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方约定。对于受限策略,静默的无隔离透传永远不合法。 提供方选择、探测、缓存和后端特定的强制执行报告归[本地提供方](../../packages/sandbox/sandbox-local/README.zh.md)所有。 diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index b660d61a20..84b8ff94c7 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -27,8 +27,8 @@ en: 7282c9ec77fd6f01 zh: 10e385a9489105e3 /deepseek-ai-dsh-sandbox-local/use-this-package/failures-and-recovery: - en: b1f9aeca3baf9561 - zh: a5681f4f49259468 + en: 3859a87c6122a1bf + zh: 55d7be15527f0347 /deepseek-ai-dsh-sandbox-local/understand-the-implementation: en: 15db6ec303151ccc zh: 91f62c1f64192f92 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 578caa7c63..55d5b9cfe8 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -56,7 +56,7 @@ With the provider mounted, a command runs under the mode you resolve per call. E An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly. -Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. The Windows ACL backend does not inspect these extra roots and keeps workspace and private-temp access only. +Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts. The Windows ACL backend does not inspect these extra roots and keeps workspace and private-temp access only. Its per-session preparation rejects with `SandboxPreparationError` before the command starts: `SANDBOX_INVALID_TEMP_ROOT` when the host temp root lies inside the workspace, and `SANDBOX_PREPARATION_FAILED` when a SID, ACL grant, or private temp directory cannot be created, with the `Win32Error` or Node error as `cause` and partial grants rolled back. ----- diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 7787decc5b..69103da191 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -56,7 +56,7 @@ kind: "package-reference" 不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。 -Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。Windows ACL 后端不检查这些额外根目录,仅保留工作区与私有临时目录访问权限。 +Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。Windows ACL 后端不检查这些额外根目录,仅保留工作区与私有临时目录访问权限。其按 session 的准备阶段会在命令启动前以 `SandboxPreparationError` 拒绝:主机临时根目录位于工作区内时为 `SANDBOX_INVALID_TEMP_ROOT`;无法创建 SID、ACL 授权或私有临时目录时为 `SANDBOX_PREPARATION_FAILED`,以 `Win32Error` 或 Node 错误作为 `cause`,并回滚已创建的部分授权。 ----- diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 52cea43d23..af8dae004f 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -34,13 +34,31 @@ import { } from '@deepseek-ai/node-addon-system/landlock-run' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SandboxProvider, SandboxUnavailableError, canonicalPath } from '@deepseek-ai/dsh-sandbox' +import { SandboxPreparationError, SandboxProvider, SandboxUnavailableError, canonicalPath } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' -import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, isTempRootInsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { assertNever } from '@deepseek-ai/dsh-util-values' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' +/** + * Classify one failed Windows ACL preparation step; the platform failure, or an + * `AggregateError` of it and its cleanup failures, becomes the `cause`. + * @param step - the preparation step, as the message names it. + * @param error - the step's own failure, typically a `Win32Error` or Node errno error. + * @param cleanupFailures - rollback failures that followed it. + * @returns the coded preparation failure. + */ +function aclPreparationFailure(step: string, error: unknown, cleanupFailures: readonly unknown[] = []): SandboxPreparationError { + const failed = cleanupFailures.length === 0 ? `${step} failed` : `${step} failed and its cleanup also failed` + return new SandboxPreparationError( + `sandbox-local: windows-acl ${failed}: ${String(error)}. The command did not run; ` + + 'check that the current user can change permissions on the workspace and temp directories, or run with danger-full-access.', + 'SANDBOX_PREPARATION_FAILED', + { cause: cleanupFailures.length === 0 ? error : new AggregateError([error, ...cleanupFailures], `sandbox-local windows-acl ${failed}`) }, + ) +} + /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** @@ -397,16 +415,37 @@ export class LocalSandboxProvider extends SandboxProvider { * SID to enter it. A fresh provider always chooses a new path; crash * residue therefore cannot collide with or authorize a resumed session. * Fail-closed: a half-materialized temp grant is revoked and its directory - * removed before the error propagates. + * removed before the error propagates. A temp root inside the workspace + * throws `SandboxPreparationError` `SANDBOX_INVALID_TEMP_ROOT` before any ACL + * change; any other failure throws `SANDBOX_PREPARATION_FAILED` with the + * platform error as `cause`. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. * @returns the pair's private temp directory and write capability. */ private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability { - assertTempRootOutsideWorkspace(workspaceRoot, tmpdir()) + let tempInsideWorkspace: boolean + try { + tempInsideWorkspace = isTempRootInsideWorkspace(workspaceRoot, tmpdir()) + } catch (error) { + throw aclPreparationFailure('workspace and temp directory resolution', error) + } + if (tempInsideWorkspace) { + throw new SandboxPreparationError( + `sandbox-local: windows-acl temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tmpdir()}. ` + + 'A private temp directory there would inherit the workspace grant, so the command did not run. ' + + 'Set TEMP and TMP to a directory outside the workspace, or open a workspace that does not contain the temp directory.', + 'SANDBOX_INVALID_TEMP_ROOT', + ) + } const writeSid = workspaceWriteSid(workspaceRoot) if (!this.workspaceGrants.has(workspaceRoot)) { - const grant = AclWriteGrant.create(writeSid) + let grant: AclWriteGrant + try { + grant = AclWriteGrant.create(writeSid) + } catch (error) { + throw aclPreparationFailure('workspace grant', error) + } try { grant.add(workspaceRoot, true) } catch (error) { @@ -416,16 +455,21 @@ export class LocalSandboxProvider extends SandboxProvider { try { grant.dispose() } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed') + throw aclPreparationFailure('workspace grant', error, [cleanupError]) } - throw error + throw aclPreparationFailure('workspace grant', error) } this.workspaceGrants.set(workspaceRoot, grant) } const key = JSON.stringify([String(sessionId), workspaceRoot]) const existing = this.tempCapabilities.get(key) if (existing !== undefined) return existing - const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) + let tempDir: string + try { + tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) + } catch (error) { + throw aclPreparationFailure('private temp directory creation', error) + } const tempSid = tempWriteSid(tempDir) let grant: AclWriteGrant | undefined try { @@ -445,10 +489,7 @@ export class LocalSandboxProvider extends SandboxProvider { } catch (cleanupError) { cleanupFailures.push(cleanupError) } - if (cleanupFailures.length > 0) { - throw new AggregateError([error, ...cleanupFailures], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') - } - throw error + throw aclPreparationFailure('temp grant materialization', error, cleanupFailures) } const capability = { dir: tempDir, writeSid: tempSid, grant } this.tempCapabilities.set(key, capability) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index 7f69bc3ddf..c98fbf0eb9 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -5,7 +5,7 @@ * is mocked; native access checks live in sandbox-windows-acl's runner suite. */ -import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -21,6 +21,7 @@ const mockState = vi.hoisted(() => ({ /** Restrict an add failure to standing (workspace) or revocable (temp). */ addFailureStanding: undefined as boolean | undefined, createTempFailure: undefined as Error | undefined, + createWorkspaceFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) @@ -35,6 +36,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { } static create(writeSid: string): MockAclWriteGrant { if (writeSid.startsWith('TEMP:') && mockState.createTempFailure !== undefined) throw mockState.createTempFailure + if (!writeSid.startsWith('TEMP:') && mockState.createWorkspaceFailure !== undefined) throw mockState.createWorkspaceFailure return new MockAclWriteGrant(writeSid) } add(path: string, standing = false): void { @@ -52,12 +54,10 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { return { registerAclDiagnosisSkill: vi.fn(), AclWriteGrant: MockAclWriteGrant, - assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => { + isTempRootInsideWorkspace: (workspaceRoot: string, tempRoot: string) => { const workspace = realpathSync.native(workspaceRoot) const temp = realpathSync.native(tempRoot) - if (temp === workspace || temp.startsWith(`${workspace}${process.platform === 'win32' ? '\\' : '/'}`)) { - throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) - } + return temp === workspace || temp.startsWith(`${workspace}${process.platform === 'win32' ? '\\' : '/'}`) }, workspaceWriteSid: () => 'S-1-4-42-42', tempWriteSid: (path: string) => `TEMP:${path}`, @@ -91,6 +91,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.addFailure = undefined mockState.addFailureStanding = undefined mockState.createTempFailure = undefined + mockState.createWorkspaceFailure = undefined mockState.disposeFailure = undefined }) @@ -250,25 +251,87 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.addFailure = new Error('workspace grant exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-fail'), - })).rejects.toThrow('workspace grant exploded') + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_PREPARATION_FAILED', + cause: mockState.addFailure, + message: expect.stringContaining('workspace grant failed: Error: workspace grant exploded') as string, + }) expect(mockState.grants).toHaveLength(1) expect(mockState.grants[0]!.disposed).toBe(true) mockState.disposeFailure = new Error('workspace cleanup exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'), - })).rejects.toThrow(/workspace grant failed and its cleanup also failed/u) + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_PREPARATION_FAILED', + message: expect.stringMatching(/workspace grant failed and its cleanup also failed/u) as string, + cause: expect.objectContaining({ errors: [mockState.addFailure, mockState.disposeFailure] }) as unknown, + }) expect(mockState.grants).toHaveLength(2) } finally { cleanup() } }) + it('codes a workspace SID allocation failure and an unresolvable workspace before any grant', async () => { + const { sandbox } = await setup() + try { + const ws = workspaceRoot() + scratch.push(ws) + mockState.createWorkspaceFailure = new Error('ConvertStringSidToSidW failed (Win32 1337)') + await expect(sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sid-fail'), + })).rejects.toMatchObject({ name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', cause: mockState.createWorkspaceFailure }) + await expect(sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: join(ws, 'missing'), sessionId: SessionId('missing'), + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_PREPARATION_FAILED', + message: expect.stringContaining('workspace and temp directory resolution failed') as string, + cause: expect.objectContaining({ code: 'ENOENT' }) as unknown, + }) + expect(mockState.grants).toHaveLength(0) + } finally { + cleanup() + } + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('codes a private temp directory that cannot be created', async () => { + const { sandbox } = await setup() + const ws = workspaceRoot() + const lockedTemp = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-acl-locked-temp-'))) + scratch.push(ws, lockedTemp) + const previous = process.env.TMPDIR + chmodSync(lockedTemp, 0o500) + process.env.TMPDIR = lockedTemp + try { + await expect(sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('mkdtemp-fail'), + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_PREPARATION_FAILED', + message: expect.stringContaining('private temp directory creation failed') as string, + cause: expect.objectContaining({ code: 'EACCES' }) as unknown, + }) + } finally { + if (previous === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = previous + chmodSync(lockedTemp, 0o700) + cleanup() + } + }) + it('rejects a workspace containing the ambient temp root before any ACL mutation', async () => { const { sandbox } = await setup() await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: realpathSync.native(tmpdir()), sessionId: SessionId('overlap'), - })).rejects.toThrow(/temp root must be outside the workspace/u) + })).rejects.toMatchObject({ + name: 'SandboxPreparationError', + code: 'SANDBOX_INVALID_TEMP_ROOT', + message: expect.stringMatching(/temp root must be outside the workspace/u) as string, + }) expect(mockState.grants).toHaveLength(0) }) @@ -281,7 +344,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.createTempFailure = new Error('temp SID creation exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('create-fail'), - })).rejects.toThrow('temp SID creation exploded') + })).rejects.toMatchObject({ code: 'SANDBOX_PREPARATION_FAILED', cause: mockState.createTempFailure }) expect(mockState.grants).toHaveLength(1) // workspace only; random temp was removed mockState.createTempFailure = undefined @@ -289,7 +352,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { mockState.addFailure = new Error('temp add exploded') await expect(sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('add-fail'), - })).rejects.toThrow('temp add exploded') + })).rejects.toMatchObject({ name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', cause: mockState.addFailure }) const failedTempGrant = mockState.grants.at(-1) expect(failedTempGrant?.disposed).toBe(true) expect(failedTempGrant?.added).toHaveLength(1) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 5de5d47b72..2af0d2d6c7 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -56,7 +56,7 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' -export { assertTempRootOutsideWorkspace } from './path-boundary.ts' +export { assertTempRootOutsideWorkspace, isTempRootInsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ diff --git a/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts b/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts index 7c8dcb2596..c801bbb995 100644 --- a/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts +++ b/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts @@ -14,13 +14,24 @@ function containsDirectory(root: string, candidate: string): boolean { } /** - * Reject a temp parent that is inside the workspace: every child created + * Whether a temp parent is inside the workspace, where every child created * below it would inherit the standing workspace capability. * @param workspaceRoot - the canonical workspace root that receives the standing ACE. * @param tempRoot - the existing parent beneath which a private temp child would be created. + * @returns whether the canonical workspace contains the canonical temp root. + * @throws the filesystem error when either directory cannot be resolved. + */ +export function isTempRootInsideWorkspace(workspaceRoot: string, tempRoot: string): boolean { + return containsDirectory(workspaceRoot, tempRoot) +} + +/** + * Reject a temp parent that is inside the workspace (see {@link isTempRootInsideWorkspace}). + * @param workspaceRoot - the canonical workspace root that receives the standing ACE. + * @param tempRoot - the existing parent beneath which a private temp child would be created. */ export function assertTempRootOutsideWorkspace(workspaceRoot: string, tempRoot: string): void { - if (containsDirectory(workspaceRoot, tempRoot)) { + if (isTempRootInsideWorkspace(workspaceRoot, tempRoot)) { throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) } } diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 9bdd154f3a..7cd5cd3e7c 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -27,11 +27,11 @@ en: d3c906ddb4c47cbc zh: d264b9103e3566d6 /deepseek-ai-dsh-sandbox/use-this-package/denied-calls-and-escalation: - en: 3960c33a1e322cf9 - zh: 4d85c962067fe7a1 + en: 0572a2aa3208873f + zh: 07e5d481a637a5af /deepseek-ai-dsh-sandbox/use-this-package/fail-closed-behavior: - en: 6bf240632a7a6bf5 - zh: ebf414f5cf435875 + en: b3d36b28566cadd4 + zh: 22cf905bf574865a /deepseek-ai-dsh-sandbox/understand-the-implementation: en: 7e13d669ecc96d59 zh: b5fe7ae65812d926 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 3bd771b271..3db483c834 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -63,11 +63,11 @@ Enforcement is reported per call: `full` means the backend governs every promise ### Denied calls and escalation -When a confined call is denied, the operation reports a denial marker naming the mode — `[sandbox: file access denied under mode]` — and, when the composition advertises escalation, an escalation hint. The model may retry the exact call once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a `justification`; the approval service obtains consent for the operation. The request retains its audit reason and supplies English, Chinese, and Japanese presentation copy from `src/locales.ts`, with the model's justification unchanged. Missing translations follow the locale service's fallback chain, ultimately English; requesters own their translated presentation text. A wider mode requires approval and applies to that one call only. Repeating the call's effective mode succeeds without approval; narrower targets remain invalid. +When a confined call is denied, the operation reports a denial marker naming the mode — `[sandbox: file access denied under mode]` — and, when the composition advertises escalation, an escalation hint. The model may retry the exact call once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a `justification`; the approval service obtains consent for the operation. The request retains its audit reason and supplies English, Chinese, and Japanese presentation copy from `src/locales.ts`, with the model's justification unchanged. Missing translations follow the locale service's fallback chain, ultimately English; requesters own their translated presentation text. A wider mode requires approval and applies to that one call only. Repeating the call's effective mode succeeds without approval; narrower targets remain invalid. An escalation that yields no grant throws `SandboxEscalationError` before anything runs: `SANDBOX_ESCALATION_INVALID_ARGUMENT` for unpaired fields, a blank justification, or a target that is not strictly wider; `SANDBOX_ESCALATION_UNAVAILABLE` when the composition has no sandboxing executor, approval service, calling Agent, or approval channel; `SANDBOX_ESCALATION_REJECTED` when the user declines; and `SANDBOX_ESCALATION_CANCELLED` when the approval request ends without a decision. If the call itself was cancelled, the call's cancellation propagates instead. ### Fail-closed behavior -When no backend can enforce the requested mode, the call fails with `SANDBOX_UNAVAILABLE` rather than running unconfined; the error text names the missing platform runner. A backend that fails after starting also reports a structured runner-failure signature, so a broken sandbox is distinguishable from a command failure. +When no backend can enforce the requested mode, the call fails with `SANDBOX_UNAVAILABLE` rather than running unconfined; the error text names the missing platform runner. A backend that fails after starting also reports a structured runner-failure signature, so a broken sandbox is distinguishable from a command failure. A provider that cannot prepare host state for an enforceable mode, such as a Windows ACL grant or private temp directory, rejects with `SandboxPreparationError`: `SANDBOX_PREPARATION_FAILED` carries the platform error as `cause`, and `SANDBOX_INVALID_TEMP_ROOT` reports a host temp root inside the workspace. ----- diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index f2757c7300..6e9710cde9 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -63,11 +63,11 @@ kind: "package-reference" ### 被拒绝的调用与升权 -受限调用被拒绝时,操作会报告指明模式的拒绝标记——`[sandbox: file access denied under mode]`——组合声明升权能力时还会给出升权提示。模型可以用 `sandbox_permissions`(足以放行的最窄更宽模式)加 `justification` 重试一次完全相同的调用;审批服务为操作取得同意。请求保留审计原因,同时从 `src/locales.ts` 提供英文、中文和日文展示文案,模型给出的理由保持原样。缺失的译文遵循语言服务的回退链,最终回退到英文;请求方负责自己的展示译文。更宽的模式需要审批,且只作用于该次调用。重复指定调用的生效模式无需审批即可成功;更窄的目标仍然无效。 +受限调用被拒绝时,操作会报告指明模式的拒绝标记——`[sandbox: file access denied under mode]`——组合声明升权能力时还会给出升权提示。模型可以用 `sandbox_permissions`(足以放行的最窄更宽模式)加 `justification` 重试一次完全相同的调用;审批服务为操作取得同意。请求保留审计原因,同时从 `src/locales.ts` 提供英文、中文和日文展示文案,模型给出的理由保持原样。缺失的译文遵循语言服务的回退链,最终回退到英文;请求方负责自己的展示译文。更宽的模式需要审批,且只作用于该次调用。重复指定调用的生效模式无需审批即可成功;更窄的目标仍然无效。未取得授权的升权会在任何命令运行前抛出 `SandboxEscalationError`:字段未配对、理由为空或目标并非严格更宽时为 `SANDBOX_ESCALATION_INVALID_ARGUMENT`;组合中没有受限执行器、审批服务、调用方 Agent 或审批通道时为 `SANDBOX_ESCALATION_UNAVAILABLE`;用户拒绝时为 `SANDBOX_ESCALATION_REJECTED`;审批请求未作决定即结束时为 `SANDBOX_ESCALATION_CANCELLED`。若调用本身已被取消,则改为传播该调用的取消。 ### 故障关闭行为 -没有后端能强制执行所请求的模式时,调用以 `SANDBOX_UNAVAILABLE` 失败,而不是不受限制地运行;错误文本会指明缺失的平台 runner。启动后失败的后端还会报告结构化的 runner 失败签名,因此损坏的沙箱可以与命令失败区分开。 +没有后端能强制执行所请求的模式时,调用以 `SANDBOX_UNAVAILABLE` 失败,而不是不受限制地运行;错误文本会指明缺失的平台 runner。启动后失败的后端还会报告结构化的 runner 失败签名,因此损坏的沙箱可以与命令失败区分开。提供方无法为可强制执行的模式准备主机状态(例如 Windows ACL 授权或私有临时目录)时,以 `SandboxPreparationError` 拒绝:`SANDBOX_PREPARATION_FAILED` 以平台错误作为 `cause`,`SANDBOX_INVALID_TEMP_ROOT` 表示主机临时根目录位于工作区内。 ----- diff --git a/packages/sandbox/sandbox/src/escalation.ts b/packages/sandbox/sandbox/src/escalation.ts index c912fdd080..84d5f49561 100644 --- a/packages/sandbox/sandbox/src/escalation.ts +++ b/packages/sandbox/sandbox/src/escalation.ts @@ -16,10 +16,45 @@ * @module dsh-sandbox/escalation */ +import { HarnessError } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' import { escalationDisplayReason } from './locales.ts' import type { SandboxMode } from './index.ts' +/** + * Every {@link SandboxEscalationError} code: + * - `SANDBOX_ESCALATION_INVALID_ARGUMENT` — the escalation fields are unpaired, the justification is blank, or the + * target is not strictly wider than the call's effective mode; the model corrects the request. + * - `SANDBOX_ESCALATION_UNAVAILABLE` — the composition cannot ask for approval: no sandboxing executor, no approval + * service, no calling Agent, or no approval channel. + * - `SANDBOX_ESCALATION_REJECTED` — the user declined the escalation; the operation stays denied. + * - `SANDBOX_ESCALATION_CANCELLED` — the approval request ended without a decision while the call itself was not cancelled. + */ +export const SANDBOX_ESCALATION_ERROR_CODES = [ + 'SANDBOX_ESCALATION_INVALID_ARGUMENT', + 'SANDBOX_ESCALATION_UNAVAILABLE', + 'SANDBOX_ESCALATION_REJECTED', + 'SANDBOX_ESCALATION_CANCELLED', +] as const + +/** One code from {@link SANDBOX_ESCALATION_ERROR_CODES}. */ +export type SandboxEscalationErrorCode = typeof SANDBOX_ESCALATION_ERROR_CODES[number] + +/** A `sandbox_permissions` request that did not produce a grant; nothing executed. */ +export class SandboxEscalationError extends HarnessError { + override readonly code: SandboxEscalationErrorCode + + /** + * @param message - the model-facing explanation and the safe next step. + * @param code - the failure class consumers route on. + */ + constructor(message: string, code: SandboxEscalationErrorCode) { + super(message, code) + this.name = 'SandboxEscalationError' + this.code = code + } +} + /** * The strictly-wider table: what a call whose effective mode is the key may * escalate TO. Checked at EXECUTION, never baked into a tool schema — the @@ -46,18 +81,19 @@ export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'd * `sandbox_permissions` and `justification` travel together — an approval * prompt without a reason, or a reason driving nothing, is a malformed ask — * and the justification must be a non-empty sentence. + * A violation throws {@link SandboxEscalationError} `SANDBOX_ESCALATION_INVALID_ARGUMENT`. * @param sandboxPermissions - the raw `sandbox_permissions` argument, if given. * @param justification - the raw `justification` argument, if given. */ export function validateEscalationArgs(sandboxPermissions: string | undefined, justification: string | undefined): void { if (sandboxPermissions !== undefined && justification === undefined) { - throw new Error('invalid escalation: sandbox_permissions requires a justification') + throw new SandboxEscalationError('invalid escalation: sandbox_permissions requires a justification', 'SANDBOX_ESCALATION_INVALID_ARGUMENT') } if (justification !== undefined && sandboxPermissions === undefined) { - throw new Error('invalid escalation: justification is only valid together with sandbox_permissions') + throw new SandboxEscalationError('invalid escalation: justification is only valid together with sandbox_permissions', 'SANDBOX_ESCALATION_INVALID_ARGUMENT') } if (justification !== undefined && justification.trim().length === 0) { - throw new Error('invalid justification: expected a non-empty sentence') + throw new SandboxEscalationError('invalid justification: expected a non-empty sentence', 'SANDBOX_ESCALATION_INVALID_ARGUMENT') } } @@ -164,7 +200,8 @@ export interface EscalationRequest { * effective mode returns it without approval. A strictly wider mode requires * approval and applies only to this call. Narrower or unsupported targets, * missing approval services or agents for widening, and non-grant outcomes - * throw before execution. + * throw {@link SandboxEscalationError} before execution; a `cancelled` + * outcome while `approval.signal` is aborted rethrows the signal's reason. * @param request - the escalation to judge (see {@link EscalationRequest}). * @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}). * @returns the granted mode, consumed by the one call that asked. @@ -176,13 +213,22 @@ export async function approveEscalation(request: EscalationRequest, approv // deliberately not a schema constraint (the enum is the closed target // vocabulary; the effective mode is per-call truth). if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) { - throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`) + throw new SandboxEscalationError( + `sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`, + 'SANDBOX_ESCALATION_INVALID_ARGUMENT', + ) } if (approval.approver === undefined) { - throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`) + throw new SandboxEscalationError( + `sandbox escalation to "${mode}" requires approval, but no approval service is composed`, + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) } if (approval.agent === undefined) { - throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`) + throw new SandboxEscalationError( + `sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`, + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) } // Self-contained for the audit trail: approval/asked stores this reason, // and the target mode is part of the grant's identity. @@ -198,9 +244,20 @@ export async function approveEscalation(request: EscalationRequest, approv // The schema enum already pinned `mode` to the closed target vocabulary; // the check above proved it is strictly wider. case 'allowed-once': return mode as SandboxMode - case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"; it stays denied, so stop and explain instead of working around it`) - case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`) - case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`) + case 'rejected': + throw new SandboxEscalationError( + `the user rejected escalating this ${subject} to "${mode}"; it stays denied, so stop and explain instead of working around it`, + 'SANDBOX_ESCALATION_REJECTED', + ) + case 'cancelled': + // A cancelled call keeps its own cancellation so the registry records it as aborted. + approval.signal?.throwIfAborted() + throw new SandboxEscalationError(`approval for escalating to "${mode}" was cancelled`, 'SANDBOX_ESCALATION_CANCELLED') + case 'unavailable': + throw new SandboxEscalationError( + `sandbox escalation to "${mode}" requires approval, but no approval channel is available`, + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) default: return assertNever(outcome, 'EscalationOutcome') } } diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index bd22dcb5d6..69d9100f66 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -11,6 +11,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session' export { ESCALATION_TARGETS, + SANDBOX_ESCALATION_ERROR_CODES, + SandboxEscalationError, WIDER_MODES, approveEscalation, escalationHintMarker, @@ -18,7 +20,13 @@ export { sandboxPermissionsDescription, validateEscalationArgs, } from './escalation.ts' -export type { EscalationApproval, EscalationApprover, EscalationOutcome, EscalationRequest } from './escalation.ts' +export type { + EscalationApproval, + EscalationApprover, + EscalationOutcome, + EscalationRequest, + SandboxEscalationErrorCode, +} from './escalation.ts' export { canonicalPath, workspaceWritableRoots, writableRoots, SandboxInvalidRootError } from './roots.ts' /** @@ -152,6 +160,38 @@ export class SandboxUnavailableError extends HarnessError { } } +/** + * Every {@link SandboxPreparationError} code: + * - `SANDBOX_PREPARATION_FAILED` — the provider could not set up host state the confined process needs, such as a + * Windows ACL grant or a private temp directory; `cause` holds the platform failure. + * - `SANDBOX_INVALID_TEMP_ROOT` — the host temp root lies inside the workspace, so a private temp directory would + * inherit the workspace grant; move the temp root or the workspace. + */ +export const SANDBOX_PREPARATION_ERROR_CODES = ['SANDBOX_PREPARATION_FAILED', 'SANDBOX_INVALID_TEMP_ROOT'] as const + +/** One code from {@link SANDBOX_PREPARATION_ERROR_CODES}. */ +export type SandboxPreparationErrorCode = typeof SANDBOX_PREPARATION_ERROR_CODES[number] + +/** + * Thrown by {@link SandboxProvider.confine} when host preparation for an + * enforceable mode fails. The provider rolls back what it prepared before + * throwing, and the command never runs. + */ +export class SandboxPreparationError extends HarnessError { + override readonly code: SandboxPreparationErrorCode + + /** + * @param message - names the preparation step and the safe next step. + * @param code - the failure class consumers route on. + * @param options - the platform failure, or an `AggregateError` with the cleanup failures, as `cause`. + */ + constructor(message: string, code: SandboxPreparationErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SandboxPreparationError' + this.code = code + } +} + declare module '@deepseek-ai/cordis' { interface Context { sandbox: SandboxProvider diff --git a/packages/sandbox/sandbox/tests/escalation.spec.ts b/packages/sandbox/sandbox/tests/escalation.spec.ts index 987ac0105b..f485bbd1df 100644 --- a/packages/sandbox/sandbox/tests/escalation.spec.ts +++ b/packages/sandbox/sandbox/tests/escalation.spec.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest' import { ESCALATION_TARGETS, + SandboxEscalationError, WIDER_MODES, approveEscalation, escalationHintMarker, @@ -16,7 +17,20 @@ import { sandboxPermissionsDescription, validateEscalationArgs, } from '@deepseek-ai/dsh-sandbox' -import type { EscalationApprover, EscalationOutcome } from '@deepseek-ai/dsh-sandbox' +import type { EscalationApprover, EscalationOutcome, SandboxEscalationErrorCode } from '@deepseek-ai/dsh-sandbox' + +/** The structured identity a failed escalation records on `tool/result`. */ +const escalationError = (code: SandboxEscalationErrorCode): unknown => expect.objectContaining({ name: 'SandboxEscalationError', code }) + +/** The synchronous failure `run` throws. */ +function thrown(run: () => void): unknown { + try { + run() + } catch (error: unknown) { + return error + } + throw new Error('expected a throw') +} describe('the strictly-wider ladder', () => { it('read-only escalates to either wider mode; workspace-write only to full access', () => { @@ -40,6 +54,11 @@ describe('validateEscalationArgs', () => { expect(() => { validateEscalationArgs('workspace-write', undefined) }).toThrow(/requires a justification/) expect(() => { validateEscalationArgs(undefined, 'orphan reason') }).toThrow(/only valid together with sandbox_permissions/) expect(() => { validateEscalationArgs('workspace-write', ' ') }).toThrow(/non-empty sentence/) + for (const [mode, justification] of [['workspace-write', undefined], [undefined, 'orphan reason'], ['workspace-write', ' ']] as const) { + const error = thrown(() => { validateEscalationArgs(mode, justification) }) + expect(error).toBeInstanceOf(SandboxEscalationError) + expect(error).toEqual(escalationError('SANDBOX_ESCALATION_INVALID_ARGUMENT')) + } }) }) @@ -113,12 +132,16 @@ describe('approveEscalation', () => { .rejects.toThrow(/not strictly wider/) await expect(approveEscalation(req({ requestedMode: 'unknown-mode' }), spy)) .rejects.toThrow(/not strictly wider/) + await expect(approveEscalation(req({ requestedMode: 'unknown-mode' }), spy)) + .rejects.toEqual(escalationError('SANDBOX_ESCALATION_INVALID_ARGUMENT')) expect(seen).toEqual([]) }) it('a missing approval service and an agent-less call each fail closed with distinct text', async () => { await expect(approveEscalation(req(), ingredients({ approver: undefined }))).rejects.toThrow(/no approval service is composed/) await expect(approveEscalation(req(), ingredients({ agent: undefined }))).rejects.toThrow(/no agent to route it through/) + await expect(approveEscalation(req(), ingredients({ approver: undefined }))).rejects.toEqual(escalationError('SANDBOX_ESCALATION_UNAVAILABLE')) + await expect(approveEscalation(req(), ingredients({ agent: undefined }))).rejects.toEqual(escalationError('SANDBOX_ESCALATION_UNAVAILABLE')) }) it('maps each non-grant outcome to its distinct verbatim text (subject in the rejection)', async () => { @@ -130,6 +153,23 @@ describe('approveEscalation', () => { .rejects.toThrow('no approval channel is available') }) + it('codes each non-grant outcome', async () => { + await expect(approveEscalation(req(), ingredients({ approver: approver('rejected') }))) + .rejects.toEqual(escalationError('SANDBOX_ESCALATION_REJECTED')) + await expect(approveEscalation(req(), ingredients({ approver: approver('cancelled') }))) + .rejects.toEqual(escalationError('SANDBOX_ESCALATION_CANCELLED')) + await expect(approveEscalation(req(), ingredients({ approver: approver('unavailable') }))) + .rejects.toEqual(escalationError('SANDBOX_ESCALATION_UNAVAILABLE')) + }) + + it('a cancelled approval for an aborted call rethrows the call\'s cancellation', async () => { + const controller = new AbortController() + const reason = new DOMException('caller stopped', 'AbortError') + controller.abort(reason) + await expect(approveEscalation(req(), ingredients({ approver: approver('cancelled'), signal: controller.signal }))) + .rejects.toBe(reason) + }) + it('an outcome outside the closed union trips the exhaustiveness guard (defensive)', async () => { await expect(approveEscalation(req(), ingredients({ approver: approver('bogus' as never) }))).rejects.toThrow() }) diff --git a/packages/sandbox/sandbox/tests/vocabulary.spec.ts b/packages/sandbox/sandbox/tests/vocabulary.spec.ts index caba42b2a7..26db8fe796 100644 --- a/packages/sandbox/sandbox/tests/vocabulary.spec.ts +++ b/packages/sandbox/sandbox/tests/vocabulary.spec.ts @@ -7,7 +7,8 @@ */ import { describe, expect, it } from 'vitest' -import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { SANDBOX_PREPARATION_ERROR_CODES, SANDBOX_UNAVAILABLE, SandboxPreparationError, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' describe('SandboxUnavailableError', () => { it('carries the structured { name, code } identity consumers key on', () => { @@ -33,3 +34,13 @@ describe('SandboxUnavailableError', () => { expect(error.message).toContain('Runner failure: landlock-run: landlock is not enforced by this kernel') }) }) + +describe('SandboxPreparationError', () => { + it('carries a literal name, a closed code, and the platform failure as cause', () => { + const cause = new Error('SetNamedSecurityInfoW failed (Win32 5)') + const error = new SandboxPreparationError('cannot grant the workspace', 'SANDBOX_PREPARATION_FAILED', { cause }) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', cause }) + expect(SANDBOX_PREPARATION_ERROR_CODES).toEqual(['SANDBOX_PREPARATION_FAILED', 'SANDBOX_INVALID_TEMP_ROOT']) + }) +}) From 45db11970ccdf4e4ad5643fc11e45b702ac125e7 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 17:59:51 +0800 Subject: [PATCH 16/81] feat(working-directory): add WorkingDirectoryError and code directory failures The working-directory service throws WorkingDirectoryError: WORKING_DIRECTORY_INVALID for an empty cd, WORKING_DIRECTORY_NOT_FOUND for a missing target, and WORKING_DIRECTORY_UNAVAILABLE when the current and original directories are both gone or the service was disposed. The working_directory tool reports a missing Agent as ToolContextUnavailableError and an empty cd as ToolArgsError. sandbox-policy reports a session cwd it cannot use as a workspace root as SandboxInvalidRootError (SANDBOX_INVALID_ROOT), whose constructor now names the role of the rejected path. Refs #7869 --- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +-- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 2 +- packages/sandbox/sandbox-policy/src/index.ts | 29 ++++++++++++---- .../sandbox-policy/tests/policy.spec.ts | 14 ++++++++ packages/sandbox/sandbox/src/roots.ts | 11 ++++-- .../tool-working-directory/README.i18n.yaml | 4 +-- .../session/tool-working-directory/README.md | 2 +- .../tool-working-directory/README.zh.md | 2 +- .../tool-working-directory/src/index.ts | 5 +-- .../working-directory/README.i18n.yaml | 4 +-- packages/session/working-directory/README.md | 2 +- .../session/working-directory/README.zh.md | 2 +- .../session/working-directory/src/error.ts | 34 +++++++++++++++++++ .../session/working-directory/src/index.ts | 33 +++++++++++++----- .../tests/working-directory.spec.ts | 21 +++++++++++- 16 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 packages/session/working-directory/src/error.ts diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index a5cc96a557..9bef469d7d 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6704447e52ec295b zh: 9ed812d9e29d5533 /deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration: - en: 0616478ef5bb0566 - zh: e3a5802aefcf8424 + en: 0b62eae224181946 + zh: 2e77e7f49bd074d4 /deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: en: 841ea47ef1ed7ee6 zh: cd573e67a67742e2 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 022ba841e4..0d26190122 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -45,7 +45,7 @@ Load the package with a default mode; the fail-safe default is `read-only`, and | Field | Default | Meaning | |---|---|---| | `mode` | `read-only` | The deployment default mode a session starts from, validated at load | -| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd | +| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd; a cwd containing a NUL byte fails the call with `SANDBOX_INVALID_ROOT` | | `additionalWritableRoots` | `[]` | Physical absolute directories also writable under `workspace-write`; Windows file tools honor them, Windows subprocesses ignore them, and `read-only` grants no access | For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, non-directories, and symlinked directories on each call, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 49d976c7bf..a436f298d5 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `mode` | `read-only` | 会话起始的部署默认模式,加载时验证 | -| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd | +| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd;cwd 含 NUL 字节时调用以 `SANDBOX_INVALID_ROOT` 失败 | | `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的物理绝对目录;Windows 文件工具执行这些授权,Windows 子进程忽略它们,`read-only` 不授予访问权限 | 对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时拒绝不存在的路径、非目录和符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 2136e33f45..8f8d5023ac 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -27,6 +27,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { z as zod } from 'zod' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' +import { SandboxInvalidRootError } from '@deepseek-ai/dsh-sandbox' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' @@ -34,10 +35,9 @@ import type {} from '@deepseek-ai/dsh-system-prompt' export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts' -/** Preserve execution-world spelling; enforcing providers resolve filesystem identity on their host. */ -function resolveWorkspaceRoot(path: string): string { - if (!isAbsolute(path) || path.includes('\0')) throw new Error('sandbox-policy: workspace root must be an absolute execution-world path without NUL bytes') - return path +/** Whether a path is usable as an execution-world grant: absolute and free of NUL bytes. */ +function isExecutionWorldPath(path: string): boolean { + return isAbsolute(path) && !path.includes('\0') } /** @@ -160,7 +160,12 @@ export class SandboxPolicyService extends Service { // runtime fact. `workspaceRoot` has NO schema default, so its fallback to // the process cwd is real branching, resolved absolute either way. this.defaultMode = config.mode as SandboxMode - this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) + // Execution-world spelling is preserved; enforcing providers resolve filesystem identity on their host. + const workspaceRoot = config.workspaceRoot ?? process.cwd() + if (!isExecutionWorldPath(workspaceRoot)) { + throw new Error('sandbox-policy: workspace root must be an absolute execution-world path without NUL bytes') + } + this.workspaceRoot = workspaceRoot this.additionalWritableRoots = resolveAdditionalWritableRoots(config.additionalWritableRoots) } @@ -197,6 +202,8 @@ export class SandboxPolicyService extends Service { * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. + * @throws `SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session's recorded cwd is + * relative or contains a NUL byte. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { const { session } = request @@ -206,12 +213,22 @@ export class SandboxPolicyService extends Service { : resolveAdditionalWritableRoots([...(this.additionalWritableRoots ?? []), ...registrations.values()]) return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, - workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), + workspaceRoot: this.sessionWorkspaceRoot(session), ...additionalWritableRoots === undefined ? {} : { additionalWritableRoots }, ...session === undefined ? {} : { sessionId: session.id }, } } + /** The session's recorded cwd, or the configured root, as the workspace-write boundary. */ + private sessionWorkspaceRoot(session: Session | undefined): string { + const cwd = session?.header.cwd + if (cwd === undefined) return this.workspaceRoot + if (!isExecutionWorldPath(cwd)) { + throw new SandboxInvalidRootError(cwd, 'the Session working directory must be an absolute execution-world path without NUL bytes', 'sandbox workspace root') + } + return cwd + } + /** * Permit writes under one directory in this Session's future workspace-write * operations. The calling context owns the effect; disposal removes only this diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 1d4588f49e..954f789c02 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -67,6 +67,20 @@ describe('SandboxPolicyService', () => { } }) + it('rejects a session cwd containing a NUL byte with SANDBOX_INVALID_ROOT', async () => { + const cwd = '/invalid\0session' + const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/ws' }) + try { + expect(() => ctx.sandboxPolicy.resolve({ session: session('sess-invalid-cwd', cwd) })).toThrow(expect.objectContaining({ + name: 'SandboxInvalidRootError', + code: 'SANDBOX_INVALID_ROOT', + message: `Invalid sandbox workspace root ${JSON.stringify(cwd)}: the Session working directory must be an absolute execution-world path without NUL bytes`, + })) + } finally { + await ctx.fiber.dispose() + } + }) + it('resolves the deployment policy for an agentless call', async () => { const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) expect(ctx.sandboxPolicy.resolve()).toEqual({ diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts index 726f3bbfdc..363469ae42 100644 --- a/packages/sandbox/sandbox/src/roots.ts +++ b/packages/sandbox/sandbox/src/roots.ts @@ -19,10 +19,15 @@ import { relative } from 'node:path' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SandboxExecutionPolicy } from './index.ts' -/** An explicit directory grant cannot be enforced without changing the caller's selected path. */ +/** A workspace root or explicit directory grant cannot be enforced without changing the caller's selected path. */ export class SandboxInvalidRootError extends HarnessError { - constructor(path: string, detail: string) { - super(`Invalid additional writable directory ${JSON.stringify(path)}: ${detail}`, 'SANDBOX_INVALID_ROOT') + /** + * @param path - the rejected path, as the caller spelled it. + * @param detail - why the path cannot be enforced. + * @param subject - the role of the path in the policy, as the message names it. + */ + constructor(path: string, detail: string, subject = 'additional writable directory') { + super(`Invalid ${subject} ${JSON.stringify(path)}: ${detail}`, 'SANDBOX_INVALID_ROOT') this.name = 'SandboxInvalidRootError' } } diff --git a/packages/session/tool-working-directory/README.i18n.yaml b/packages/session/tool-working-directory/README.i18n.yaml index 742c9ffb0a..9e45d5365a 100644 --- a/packages/session/tool-working-directory/README.i18n.yaml +++ b/packages/session/tool-working-directory/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 87d7d2dbd7777429 /deepseek-ai-dsh-tool-working-directory/use-this-package: - en: bb750522dbdb3fef - zh: 85586a5c2137afa9 + en: 83ab57140ebea28e + zh: 213c36a664a21f65 /deepseek-ai-dsh-tool-working-directory/understand-the-implementation: en: f361fe510916fce8 zh: a2127f768ef9ac95 diff --git a/packages/session/tool-working-directory/README.md b/packages/session/tool-working-directory/README.md index 8eadd039a3..379743d651 100644 --- a/packages/session/tool-working-directory/README.md +++ b/packages/session/tool-working-directory/README.md @@ -25,7 +25,7 @@ Read the Session’s current directory or enter an existing directory with the s ## Use this package -Mount alongside `tools` and `workingDirectory`. Call `working_directory({})` to read or `working_directory({ cd: "src" })` to change directory. The tool has no configuration fields. +Mount alongside `tools` and `workingDirectory`. Call `working_directory({})` to read or `working_directory({ cd: "src" })` to change directory. The tool has no configuration fields. A call without a calling Agent fails with `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), an empty `cd` with `ToolArgsError` (`INVALID_ARGS`), and a missing directory with the service's `WorkingDirectoryError` codes. ```yaml - name: '@deepseek-ai/dsh-tool-working-directory' diff --git a/packages/session/tool-working-directory/README.zh.md b/packages/session/tool-working-directory/README.zh.md index 50c4b638fb..5c8cc05bd6 100644 --- a/packages/session/tool-working-directory/README.zh.md +++ b/packages/session/tool-working-directory/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -与 `tools` 和 `workingDirectory` 一起挂载。调用 `working_directory({})` 读取目录,或调用 `working_directory({ cd: "src" })` 改变目录。此工具没有配置字段。 +与 `tools` 和 `workingDirectory` 一起挂载。调用 `working_directory({})` 读取目录,或调用 `working_directory({ cd: "src" })` 改变目录。此工具没有配置字段。没有调用方 Agent 的调用以 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`)失败,`cd` 为空以 `ToolArgsError`(`INVALID_ARGS`)失败,目录不存在时使用服务的 `WorkingDirectoryError` code。 ```yaml - name: '@deepseek-ai/dsh-tool-working-directory' diff --git a/packages/session/tool-working-directory/src/index.ts b/packages/session/tool-working-directory/src/index.ts index f0aa8bc4f0..89022d3e57 100644 --- a/packages/session/tool-working-directory/src/index.ts +++ b/packages/session/tool-working-directory/src/index.ts @@ -4,7 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-working-directory' /** Cordis plugin name. */ @@ -32,7 +32,8 @@ export function apply(ctx: Context): void { render: (_args, value) => [{ type: 'text', text: value.cwd }], }, async execute(args, exec) { - if (exec.agent === undefined) throw new Error('working_directory requires an agent Session') + if (exec.agent === undefined) throw new ToolContextUnavailableError('working_directory requires an agent Session') + if (args.cd === '') throw new ToolArgsError(['cd must not be empty; omit it to read the current directory']) const cwd = args.cd === undefined ? await ctx.workingDirectory.ensure(exec.agent, exec.signal) : await ctx.workingDirectory.set(exec.agent, args.cd, exec.signal) diff --git a/packages/session/working-directory/README.i18n.yaml b/packages/session/working-directory/README.i18n.yaml index fd50066fe2..3fd77f1c5c 100644 --- a/packages/session/working-directory/README.i18n.yaml +++ b/packages/session/working-directory/README.i18n.yaml @@ -18,8 +18,8 @@ en: e65405026debde7c zh: e12a648eb10ed16b /deepseek-ai-dsh-working-directory/understand-the-implementation: - en: f0458147d29631b1 - zh: 6a288670dd084ffb + en: 82d7372a58bb75c5 + zh: e18ee833daa99581 /deepseek-ai-dsh-working-directory/further-exploration: en: eabff683146786c8 zh: 7997dec17802ef64 diff --git a/packages/session/working-directory/README.md b/packages/session/working-directory/README.md index 44ed9ba9dc..1a70cfbe8f 100644 --- a/packages/session/working-directory/README.md +++ b/packages/session/working-directory/README.md @@ -41,7 +41,7 @@ Directory validation and publication delegate through `working-directory/access`
Implementation internals — click to expand -The Session projection owns the effective directory and exposes it in Session observations, so cold readers can use it without activating an Agent; the header continues to identify the original project. Changes serialize per Session, validate through the filesystem provider, check cancellation and Agent lifetime, commit an event, and queue a user-context notice. If notice queuing fails after commit, the operation returns the committed directory and logs a warning; the next request or resume still receives the required directory context. Prompt assembly validates the current directory before publishing its required context contribution. Existing processes and sandbox grants keep their independent ownership. The [service implementation](src/index.ts) defines the API. +The Session projection owns the effective directory and exposes it in Session observations, so cold readers can use it without activating an Agent; the header continues to identify the original project. Changes serialize per Session, validate through the filesystem provider, check cancellation and Agent lifetime, commit an event, and queue a user-context notice. If notice queuing fails after commit, the operation returns the committed directory and logs a warning; the next request or resume still receives the required directory context. Prompt assembly validates the current directory before publishing its required context contribution. Existing processes and sandbox grants keep their independent ownership. The [service implementation](src/index.ts) defines the API. Failures are `WorkingDirectoryError` instances: `WORKING_DIRECTORY_INVALID` for an empty or malformed path, `WORKING_DIRECTORY_NOT_FOUND` when a requested change names no existing directory, and `WORKING_DIRECTORY_UNAVAILABLE` when neither the current directory nor the original project exists or the service has been disposed. No runtime invariant companion is published because the service reads its only directory state from the Session projection and checks filesystem validity at use. diff --git a/packages/session/working-directory/README.zh.md b/packages/session/working-directory/README.zh.md index 9561d73972..b5f07d5577 100644 --- a/packages/session/working-directory/README.zh.md +++ b/packages/session/working-directory/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference"
实现细节 — 点击展开 -Session 投影拥有当前目录,并通过 Session observation 暴露它,因此冷态读取方无需激活 Agent 即可使用;header 继续标识原始项目。每个 Session 的变更串行执行,通过文件系统 provider 验证,并检查取消状态与 Agent 生命周期,提交事件后排入用户上下文通知。若提交后通知入队失败,操作仍返回已提交的目录并记录警告;下一次请求或恢复仍会收到必需的目录上下文。提示词组装在发布必需的上下文条目前验证当前目录。已有进程与沙箱授权继续由各自的服务拥有。[服务实现](src/index.ts) 定义 API。 +Session 投影拥有当前目录,并通过 Session observation 暴露它,因此冷态读取方无需激活 Agent 即可使用;header 继续标识原始项目。每个 Session 的变更串行执行,通过文件系统 provider 验证,并检查取消状态与 Agent 生命周期,提交事件后排入用户上下文通知。若提交后通知入队失败,操作仍返回已提交的目录并记录警告;下一次请求或恢复仍会收到必需的目录上下文。提示词组装在发布必需的上下文条目前验证当前目录。已有进程与沙箱授权继续由各自的服务拥有。[服务实现](src/index.ts) 定义 API。失败均为 `WorkingDirectoryError`:路径为空或格式错误时为 `WORKING_DIRECTORY_INVALID`,请求切换到不存在的目录时为 `WORKING_DIRECTORY_NOT_FOUND`,当前目录与原始项目都不存在或服务已释放时为 `WORKING_DIRECTORY_UNAVAILABLE`。 不发布 `./invariant` 伴生模块,因为服务从 Session 投影读取唯一的目录状态,并在使用时检查文件系统有效性。 diff --git a/packages/session/working-directory/src/error.ts b/packages/session/working-directory/src/error.ts new file mode 100644 index 0000000000..ec2a2ef7a2 --- /dev/null +++ b/packages/session/working-directory/src/error.ts @@ -0,0 +1,34 @@ +/** Machine-routable Session working-directory failures. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Every {@link WorkingDirectoryError} code: + * - `WORKING_DIRECTORY_INVALID` — a requested directory is empty or otherwise malformed. + * - `WORKING_DIRECTORY_NOT_FOUND` — the requested directory does not exist or is not a directory; the caller picks another path. + * - `WORKING_DIRECTORY_UNAVAILABLE` — neither the current directory nor the original project exists, or the owning service was disposed. + */ +export const WORKING_DIRECTORY_ERROR_CODES = [ + 'WORKING_DIRECTORY_INVALID', + 'WORKING_DIRECTORY_NOT_FOUND', + 'WORKING_DIRECTORY_UNAVAILABLE', +] as const + +/** One code from {@link WORKING_DIRECTORY_ERROR_CODES}. */ +export type WorkingDirectoryErrorCode = typeof WORKING_DIRECTORY_ERROR_CODES[number] + +/** Working-directory failure carrying a stable {@link WorkingDirectoryErrorCode}. */ +export class WorkingDirectoryError extends HarnessError { + override readonly code: WorkingDirectoryErrorCode + + /** + * @param message - names the directory and the safe next step. + * @param code - the failure class consumers route on. + * @param options - standard `cause` chaining. + */ + constructor(message: string, code: WorkingDirectoryErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'WorkingDirectoryError' + this.code = code + } +} diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 62c3750c2c..7a0f66ede6 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -13,6 +13,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' +import { WorkingDirectoryError } from './error.ts' + +export { WORKING_DIRECTORY_ERROR_CODES, WorkingDirectoryError, type WorkingDirectoryErrorCode } from './error.ts' declare module '@deepseek-ai/cordis' { interface Events { @@ -125,7 +128,7 @@ export class WorkingDirectoryService extends Service { return assembly }, { prepend: true }) ctx.effect(() => async () => { - this.lifecycle.abort(new Error('working-directory service disposed')) + this.lifecycle.abort(new WorkingDirectoryError('working-directory service disposed', 'WORKING_DIRECTORY_UNAVAILABLE')) await Promise.allSettled(this.operations) }) } @@ -145,7 +148,8 @@ export class WorkingDirectoryService extends Service { * @param signal - cancellation for filesystem inspection. * @returns the existing directory; recovery is committed before fulfillment. * A notice failure is warned without reverting the committed state. - * @throws when the original project is also unavailable, or when a `working-directory/access` listener rejects. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also + * unavailable or the service is disposed, or the rejection of a `working-directory/access` listener. */ ensure(agent: Agent, signal?: AbortSignal): Promise { return this.serialize(agent, signal, control => this.ctx.waterfall('working-directory/access', { agent, path: null, signal: control }, async () => { @@ -161,7 +165,10 @@ export class WorkingDirectoryService extends Service { return current } const original = agent.session.header.cwd ?? this.defaultDirectory - const restored = await this.directory(original, original, control) + const restored = await this.directory(original, original, control, () => new WorkingDirectoryError( + `working-directory: directory does not exist: ${JSON.stringify(original)}; the current working directory ${JSON.stringify(current)} is also unavailable`, + 'WORKING_DIRECTORY_UNAVAILABLE', + )) this.commit(agent, restored, true, control) return restored })) @@ -174,11 +181,13 @@ export class WorkingDirectoryService extends Service { * @param signal - cancellation before the durable change. * @returns the canonical absolute directory, committed before fulfillment. * A notice failure is warned; the next request still receives the committed directory. - * @throws when the requested path is not an existing directory. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and + * `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a + * `working-directory/access` listener. */ set(agent: Agent, path: string, signal?: AbortSignal): Promise { return this.serialize(agent, signal, control => this.ctx.waterfall('working-directory/access', { agent, path, signal: control }, async () => { - if (path.length === 0) throw new Error('working-directory: cd must not be empty') + if (path.length === 0) throw new WorkingDirectoryError('working-directory: cd must not be empty', 'WORKING_DIRECTORY_INVALID') const directory = await this.directory(path, this.get(agent.session), control) this.commit(agent, directory, false, control) return directory @@ -189,13 +198,19 @@ export class WorkingDirectoryService extends Service { return `Current working directory: ${JSON.stringify(this.get(session))}.` } - private async directory(path: string, cwd: string, signal: AbortSignal): Promise { + private async directory( + path: string, + cwd: string, + signal: AbortSignal, + missing = (): WorkingDirectoryError => new WorkingDirectoryError( + `working-directory: directory does not exist: ${JSON.stringify(path)}`, + 'WORKING_DIRECTORY_NOT_FOUND', + ), + ): Promise { const target = await this.ctx.fs.resolve(path, { cwd, signal }) const info = await this.ctx.fs.stat(target, signal) signal.throwIfAborted() - if (info?.type !== 'directory') { - throw new Error(`working-directory: directory does not exist: ${JSON.stringify(path)}`) - } + if (info?.type !== 'directory') throw missing() return this.ctx.fs.processPath(target) } diff --git a/packages/session/working-directory/tests/working-directory.spec.ts b/packages/session/working-directory/tests/working-directory.spec.ts index 22fb99af47..d4769979cf 100644 --- a/packages/session/working-directory/tests/working-directory.spec.ts +++ b/packages/session/working-directory/tests/working-directory.spec.ts @@ -115,6 +115,11 @@ describe('Session working directories', () => { const agent = await f.harness.create(SessionId('removed-origin'), {}, { cwd: origin }) await rm(origin, { recursive: true }) await expect(f.ctx.workingDirectory.ensure(agent)).rejects.toThrow('directory does not exist') + await expect(f.ctx.workingDirectory.ensure(agent)).rejects.toMatchObject({ + name: 'WorkingDirectoryError', + code: 'WORKING_DIRECTORY_UNAVAILABLE', + message: expect.stringContaining('is also unavailable') as string, + }) await expect(f.ctx.workingDirectory.set(agent, f.root)).resolves.toBe(f.root) }) @@ -122,7 +127,9 @@ describe('Session working directories', () => { await using f = await fixture() await writeFile(join(f.root, 'file'), '') await expect(f.ctx.workingDirectory.set(f.agent, 'file')).rejects.toThrow('directory does not exist') + await expect(f.ctx.workingDirectory.set(f.agent, 'file')).rejects.toMatchObject({ name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_NOT_FOUND' }) await expect(f.ctx.workingDirectory.set(f.agent, '')).rejects.toThrow('cd must not be empty') + await expect(f.ctx.workingDirectory.set(f.agent, '')).rejects.toMatchObject({ name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_INVALID' }) const controller = new AbortController() controller.abort(new Error('cancelled directory change')) await expect(f.ctx.workingDirectory.set(f.agent, f.root, controller.signal)).rejects.toThrow('cancelled directory change') @@ -277,6 +284,7 @@ describe('Session working directories', () => { expect(f.agent.session.snapshotEvents().filter(event => event.type === 'working-directory/change')).toEqual([]) expect(f.harness.claim(f.agent, 'next-step', 1)).toEqual([]) await expect(directories.ensure(f.agent)).rejects.toThrow('working-directory service disposed') + await expect(directories.ensure(f.agent)).rejects.toMatchObject({ name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_UNAVAILABLE' }) expect(probe).toHaveBeenCalledOnce() } finally { release.resolve(undefined) @@ -352,7 +360,18 @@ describe('Session working directories', () => { }) expect(await execute({})).toMatchObject({ value: { cwd: f.root }, isError: false }) expect(await execute({ cd: 'child' })).toMatchObject({ value: { cwd: join(f.root, 'child') }, isError: false }) - expect(await execute({}, false)).toMatchObject({ isError: true }) + expect(await execute({}, false)).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) + expect(await execute({ cd: '' })).toMatchObject({ + isError: true, + error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } }, + }) + expect(await execute({ cd: 'missing' })).toMatchObject({ + isError: true, + error: { info: { name: 'WorkingDirectoryError', code: 'WORKING_DIRECTORY_NOT_FOUND' } }, + }) await fiber.dispose() expect(f.ctx.tools.schemas().some(tool => tool.name === 'working_directory')).toBe(false) }) From c14b6860b4ad7fa67a6e15c17a477f11b74d5e7f Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:09:13 +0800 Subject: [PATCH 17/81] feat(jobs): code registry refusals and job tool argument failures jobs-local throws JobError for a missing controller (JOB_CONTROLLER_UNAVAILABLE), the owner limit (JOB_LIMIT_REACHED), a dead owner (JOB_OWNER_NOT_LIVE), an unknown id (JOB_NOT_FOUND), and a foreign caller (JOB_FOREIGN_SESSION), and ToolContextUnavailableError for an owned registration without the agent registry. An aborted wait rejects with an error named AbortError, so the tool registry records it as ABORTED. job_output and job_kill report an empty job_id, and job_output a non-positive timeout_ms, as ToolArgsError. Refs #7869 --- packages/jobs/jobs-local/README.i18n.yaml | 4 +-- packages/jobs/jobs-local/README.md | 2 +- packages/jobs/jobs-local/README.zh.md | 2 +- packages/jobs/jobs-local/package.json | 6 ++-- packages/jobs/jobs-local/src/index.ts | 30 +++++++++++++------ packages/jobs/jobs-local/tests/jobs.spec.ts | 14 ++++++++- packages/jobs/jobs-local/tsconfig.json | 3 ++ packages/jobs/tool-jobs/src/index.ts | 13 ++++++-- .../jobs/tool-jobs/tests/tool-jobs.spec.ts | 13 ++++++-- pnpm-lock.yaml | 3 ++ 10 files changed, 68 insertions(+), 22 deletions(-) diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index dc37d78e1e..93045928da 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -33,8 +33,8 @@ en: ae926795d8aaa88f zh: 0e6ec0265eefccf7 /deepseek-ai-dsh-jobs-local/use-this-package/what-can-go-wrong: - en: e1f4511c458dff3b - zh: bea247c2a365a646 + en: 3737bc2cfa7f7012 + zh: 07bc090cbfa44584 /deepseek-ai-dsh-jobs-local/understand-the-implementation: en: f782557ca8da3138 zh: e8f0082b9692cacc diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index 3f0f80d296..1f753e7f93 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -64,7 +64,7 @@ Lifecycle facts are appended synchronously at the registration or settlement com ### What can go wrong -Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Live registry records disappear when the harness process exits; persisted lifecycle events remain. +Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. Refusals are `JobError` instances: `JOB_CONTROLLER_UNAVAILABLE`, `JOB_LIMIT_REACHED`, `JOB_OWNER_NOT_LIVE`, `JOB_NOT_FOUND`, and `JOB_FOREIGN_SESSION`; an owned registration without the agent registry throws `ToolContextUnavailableError`. A wait whose signal aborts rejects with an error named `AbortError`. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Live registry records disappear when the harness process exits; persisted lifecycle events remain. ----- diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index c00a6527d2..30dc26d3b0 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 可能出什么问题 -没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。运行期注册表记录会在 harness 进程退出时消失;已持久化的生命周期事件会保留。 +没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。拒绝均为 `JobError`:`JOB_CONTROLLER_UNAVAILABLE`、`JOB_LIMIT_REACHED`、`JOB_OWNER_NOT_LIVE`、`JOB_NOT_FOUND` 与 `JOB_FOREIGN_SESSION`;没有 agent 注册表时登记带所有者的 job 会抛出 `ToolContextUnavailableError`。signal 中止的等待以名为 `AbortError` 的错误拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。运行期注册表记录会在 harness 进程退出时消失;已持久化的生命周期事件会保留。 ----- diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index b635f6289d..b7e73d4753 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-tools": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -51,6 +52,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index daa4ce0db3..4e1a0959f6 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -17,7 +17,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { SessionId } from '@deepseek-ai/dsh-session' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' +import { JobError, JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' +import { ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKind, JobOutcome, JobOutputRead, JobOutputSource, JobRead, JobRegisteredData, JobSettleCause, JobSettledData, JobSpec, JobStatus, JobToolCall, JobView, @@ -117,6 +118,13 @@ interface TrackedJob { spillPaths: (string | undefined)[] } +/** The cancellation a wait reports when its caller's signal aborts; the name lets the tool registry classify it. */ +function waitAborted(): Error { + const error = new Error('wait aborted') + error.name = 'AbortError' + return error +} + /** True for the three terminal {@link JobStatus} values. */ function isTerminal(status: JobStatus): boolean { return status === 'completed' || status === 'killed' || status === 'failed' @@ -207,7 +215,10 @@ export class LocalJobRegistry extends JobRegistry { start(spec: JobSpec): JobId { const owner = this.resolveOwner(spec.owner) if (!this.servesOwner(owner)) { - throw new Error('background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)') + throw new JobError( + 'background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)', + 'JOB_CONTROLLER_UNAVAILABLE', + ) } if (spec.kind.length === 0) throw new Error('invalid job kind: expected a non-empty string') if (spec.label.length === 0) throw new Error('invalid job label: expected a non-empty string') @@ -219,8 +230,9 @@ export class LocalJobRegistry extends JobRegistry { const active = this.activeJobCount(owner) if (active >= this.maxConcurrentJobsPerOwner) { - throw new Error( + throw new JobError( `background job limit reached for this owner (limit: ${this.maxConcurrentJobsPerOwner}); use job_kill to stop an unneeded job, wait for it to finish, then retry`, + 'JOB_LIMIT_REACHED', ) } @@ -362,11 +374,11 @@ export class LocalJobRegistry extends JobRegistry { if (session === undefined) return undefined const agents = this.selfCtx.get('agents') if (agents === undefined) { - throw new Error('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + throw new ToolContextUnavailableError('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') } const owner = agents.get(session) if (owner === undefined) { - throw new Error(`session "${session}" has no live agent (background job owner must be live)`) + throw new JobError(`session "${session}" has no live agent (background job owner must be live)`, 'JOB_OWNER_NOT_LIVE') } return owner } @@ -397,7 +409,7 @@ export class LocalJobRegistry extends JobRegistry { /** Look up a job and enforce caller access. */ private expect(id: JobId, caller?: SessionId): TrackedJob { const job = this.store.get(id) - if (job === undefined) throw new Error(`unknown job ${id}`) + if (job === undefined) throw new JobError(`unknown job ${id}`, 'JOB_NOT_FOUND') this.assertAccess(job, caller) return job } @@ -409,7 +421,7 @@ export class LocalJobRegistry extends JobRegistry { */ private assertAccess(job: TrackedJob, caller: SessionId | undefined): void { if (job.owner !== undefined && job.owner.id !== caller) { - throw new Error(`job ${job.id} belongs to another session`) + throw new JobError(`job ${job.id} belongs to another session`, 'JOB_FOREIGN_SESSION') } } @@ -498,7 +510,7 @@ export class LocalJobRegistry extends JobRegistry { throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`) } if (!isTerminal(job.status)) { - if (signal?.aborted) throw new Error('wait aborted') + if (signal?.aborted) throw waitAborted() // The scoped deadline distinguishes a successful wait timeout from // caller cancellation and clears its timer on every exit. using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT) @@ -517,7 +529,7 @@ export class LocalJobRegistry extends JobRegistry { if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) { resolve() } else { - reject(new Error('wait aborted')) + reject(waitAborted()) } } job.waitResolvers.add(onSettled) diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 0027549fb1..521c7647d6 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -11,7 +11,7 @@ import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' import type { - JobEvent, JobEventFilter, JobHandle, JobHooks, JobKind, JobOutcome, JobOutputSource, JobSpec, JobView, + JobErrorCode, JobEvent, JobEventFilter, JobHandle, JobHooks, JobKind, JobOutcome, JobOutputSource, JobSpec, JobView, } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local' import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -172,6 +172,9 @@ function scriptedSource(reads: { text: string; lossy?: boolean }[], channel?: 's } } +/** The structured identity a registry refusal carries. */ +const jobError = (code: JobErrorCode): { name: 'JobError'; code: JobErrorCode } => ({ name: 'JobError', code }) + describe('LocalJobRegistry.start', () => { it('preserves the SessionId brand on public owner projections', () => { expectTypeOf().toEqualTypeOf() @@ -182,6 +185,7 @@ describe('LocalJobRegistry.start', () => { await ctx.plugin(LocalJobRegistry) expect(() => ctx.jobs.start(producer().spec)) .toThrow('background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)') + expect(() => ctx.jobs.start(producer().spec)).toThrow(expect.objectContaining(jobError('JOB_CONTROLLER_UNAVAILABLE'))) }) it('refuses an owner whose own composition attaches no controller', async () => { @@ -255,6 +259,7 @@ describe('LocalJobRegistry.start', () => { const run = vi.fn((job: JobHandle) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('background job limit reached for this owner (limit: 10)') + expect(() => ctx.jobs.start({ ...blocked.spec, run })).toThrow(expect.objectContaining(jobError('JOB_LIMIT_REACHED'))) expect(run).not.toHaveBeenCalled() for (const job of live) job.settle({ status: 'completed' }) }) @@ -414,6 +419,7 @@ describe('LocalJobRegistry reads and settlement', () => { const ctx = await harness() expect(() => ctx.jobs.read(JobId('bash-99'))).toThrow('unknown job bash-99') expect(() => ctx.jobs.get(JobId('bash-99'))).toThrow('unknown job bash-99') + expect(() => ctx.jobs.get(JobId('bash-99'))).toThrow(expect.objectContaining(jobError('JOB_NOT_FOUND'))) }) it('announces settlement once with containment across listeners', async () => { @@ -601,11 +607,13 @@ describe('LocalJobRegistry.wait', () => { const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) controller.abort() await expect(wait).rejects.toThrow('wait aborted') + await expect(wait).rejects.toMatchObject({ name: 'AbortError' }) expect(ctx.jobs.list()[0]).toMatchObject({ status: 'running' }) const preAborted = new AbortController() preAborted.abort() await expect(ctx.jobs.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted') + await expect(ctx.jobs.wait(id, 5_000, undefined, preAborted.signal)).rejects.toMatchObject({ name: 'AbortError' }) }) it('an abort racing settlement in the same tick rejects the wait and leaves the settlement intact', async () => { @@ -736,6 +744,7 @@ describe('LocalJobRegistry owner isolation', () => { expect(() => foreign.kill(owned, other.id)).toThrow('belongs to another session') await expect(foreign.wait(owned, 10, other.id)).rejects.toThrow('belongs to another session') expect(() => ctx.jobs.read(owned)).toThrow('belongs to another session') + expect(() => ctx.jobs.read(owned)).toThrow(expect.objectContaining(jobError('JOB_FOREIGN_SESSION'))) }) it('list() shows only caller-owned plus unowned jobs', async () => { @@ -758,6 +767,8 @@ describe('LocalJobRegistry owner isolation', () => { ctx.jobs.attachController('test-controller') expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) .toThrow('background job ownership requires the agent registry') + expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) + .toThrow(expect.objectContaining({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' })) // The failed registration mutated nothing: no stored job. expect(ctx.jobs.list()).toEqual([]) expect(ctx.jobs.start(producer().spec)).toMatch(/^bash-[0-9a-f]{16}$/) @@ -770,6 +781,7 @@ describe('LocalJobRegistry owner isolation', () => { // Owner resolution precedes registry mutation and cleanup attachment. expect(() => ctx.jobs.start(producer({ owner: ghost }).spec)) .toThrow('has no live agent') + expect(() => ctx.jobs.start(producer({ owner: ghost }).spec)).toThrow(expect.objectContaining(jobError('JOB_OWNER_NOT_LIVE'))) expect(ctx.jobs.list(ghost.id)).toEqual([]) // A later valid registration must still attach cleanup for the live instance. diff --git a/packages/jobs/jobs-local/tsconfig.json b/packages/jobs/jobs-local/tsconfig.json index f94627dd8f..892c4da43e 100644 --- a/packages/jobs/jobs-local/tsconfig.json +++ b/packages/jobs/jobs-local/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../jobs" + }, + { + "path": "../../core/tools" } ] } diff --git a/packages/jobs/tool-jobs/src/index.ts b/packages/jobs/tool-jobs/src/index.ts index dff4681cf1..8da4efea17 100644 --- a/packages/jobs/tool-jobs/src/index.ts +++ b/packages/jobs/tool-jobs/src/index.ts @@ -15,7 +15,7 @@ import z from '@deepseek-ai/schemastery' import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContextFormed, MessageId } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-output-retention' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { JobId } from '@deepseek-ai/dsh-jobs' import type { JobEvent, JobView, JobRead } from '@deepseek-ai/dsh-jobs' @@ -208,7 +208,7 @@ function visibleOutputLimit(ctx: Context, exec: ToolExecution): number | undefin /** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */ function validateJobId(value: string): JobId { if (value.length === 0) { - throw new Error(`invalid job_id: expected a non-empty string, got ${JSON.stringify(value)}`) + throw new ToolArgsError([`invalid job_id: expected a non-empty string, got ${JSON.stringify(value)}`]) } return JobId(value) } @@ -457,6 +457,9 @@ export function apply(ctx: Context, config: Config): void { }, async execute(args, exec) { const id = validateJobId(args.job_id) + if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) { + throw new ToolArgsError([`invalid timeout_ms: expected a positive number of milliseconds, got ${JSON.stringify(args.timeout_ms)}`]) + } const jobs = ctx.jobs const agent = exec.agent // A wait this execution ends on next-step input claims the read that @@ -509,7 +512,11 @@ export function apply(ctx: Context, config: Config): void { // An input released this wait, so the read below carries the // current state; a cancellation leaves no consumer for that read, // and the claim's release delivers the settlement it withheld. - if (exec.signal.aborted) throw new Error('wait aborted') + if (exec.signal.aborted) { + const aborted = new Error('wait aborted') + aborted.name = 'AbortError' + throw aborted + } } finally { unsubscribe() } diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 4d2592a880..e782de33e2 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -569,10 +569,17 @@ describe('job_output', () => { it('rejects an empty or unknown job id as an errored result', async () => { const { ctx } = await setup() - expect((await call(ctx, 'job_output', { job_id: '' })).isError).toBe(true) + expect(await call(ctx, 'job_output', { job_id: '' })).toMatchObject({ + isError: true, + error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } }, + }) const unknown = await call(ctx, 'job_output', { job_id: 'bash-99' }) - expect(unknown.isError).toBe(true) + expect(unknown).toMatchObject({ isError: true, error: { info: { name: 'JobError', code: 'JOB_NOT_FOUND' } } }) expect(text(unknown)).toContain('unknown job bash-99') + expect(await call(ctx, 'job_output', { job_id: 'bash-99', wait: true, timeout_ms: 0 })).toMatchObject({ + isError: true, + error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } }, + }) }) }) @@ -1153,7 +1160,7 @@ describe('completion notices', () => { await tick() aborter.abort() const result = await pending - expect(result.isError).toBe(true) + expect(result).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } }) expect(text(result)).toContain('wait aborted') p.settle({ status: 'completed' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1884575900..90d655e2af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13140,6 +13140,9 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../../core/tools packages/jobs/tool-jobs: dependencies: From dbcab765f95ada1fb0eb09ba304bfefcd86d4513 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:29:33 +0800 Subject: [PATCH 18/81] fix(shell): keep coded failures on the bash and pwsh job path A foreground bash or pwsh call that waits on a job rethrew a failed preparation as new Error(view.detail), dropping the original class and code, so even SandboxUnavailableError reached tool/result without error.name or error.code. processJob now keeps the original error for a failed job and the tool rethrows it; launch and spawn rejections on every path keep a HarnessError's identity, and a platform spawn error becomes SubprocessError (SUBPROCESS_SPAWN_NOT_FOUND or _SPAWN_FAILED). Argument violations are ToolArgsError, a missing sandboxing executor for sandbox_permissions is SandboxEscalationError SANDBOX_ESCALATION_UNAVAILABLE, a disabled background call is JobError JOB_BACKGROUND_DISABLED, and a missing job runtime is ToolContextUnavailableError. Refs #7869 --- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/package.json | 6 +- packages/shell/tool-bash/src/background.ts | 21 ++++++- packages/shell/tool-bash/src/index.ts | 59 ++++++++++++++----- .../shell/tool-bash/tests/background.spec.ts | 44 +++++++++++++- packages/shell/tool-bash/tests/tools.spec.ts | 44 ++++++++++++-- packages/shell/tool-bash/tsconfig.json | 3 + packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/package.json | 6 +- packages/shell/tool-pwsh/src/background.ts | 21 ++++++- packages/shell/tool-pwsh/src/index.ts | 59 ++++++++++++++----- .../shell/tool-pwsh/tests/background.spec.ts | 51 +++++++++++++++- packages/shell/tool-pwsh/tests/tools.spec.ts | 44 +++++++++++++- packages/shell/tool-pwsh/tsconfig.json | 3 + pnpm-lock.yaml | 6 ++ 19 files changed, 322 insertions(+), 61 deletions(-) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index c1244306b1..625910149b 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -111,8 +111,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-bash/model-experience/tool-errors/what-the-model-sees: - en: d6d7068e23fc1b93 - zh: 515e8aa586415179 + en: f4cebcfd09cd3b13 + zh: 4e3954f417b4a9ed /deepseek-ai-dsh-tool-bash/model-experience/tool-errors/token-effect: en: a3a78ef476ec5e56 zh: dd2c8ad0f69e8ff1 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 045344c881..411d5da79f 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -196,7 +196,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`. +Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`). #### Token effect diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 53d2b1984e..7f222c91aa 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -196,7 +196,7 @@ renderer 输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` #### 模型看到什么 -验证与策略失败统一为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。 +验证与策略失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。 #### Token 影响 diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 3d7102e3a9..b2314e05f9 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -43,7 +43,8 @@ "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -73,6 +74,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" } } diff --git a/packages/shell/tool-bash/src/background.ts b/packages/shell/tool-bash/src/background.ts index 49da48b00b..8835c6603c 100644 --- a/packages/shell/tool-bash/src/background.ts +++ b/packages/shell/tool-bash/src/background.ts @@ -11,6 +11,16 @@ import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sand import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell' import type { JobChunk, JobHooks, JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs' +/** Job hooks plus the original error of a job that failed before its process produced an outcome. */ +export interface ProcessJobHooks extends JobHooks { + /** + * The error that ended a `failed` job, kept with its own class and code; + * undefined while the job runs and for a completed or killed job. + * @returns the original failure, boxed so any thrown value is preserved. + */ + failure(): { error: unknown } | undefined +} + /** * Sandbox facts worth the terminal detail: a runner that never ran the * command, or a denial (with the escalation hint this composition offers). @@ -94,14 +104,16 @@ export function ringDelta(chunks: readonly JobChunk[]): string { * Adapt asynchronous shell preparation after job admission without exposing a partial process. * @param start - starts the process with job-owned cancellation. * @param outcome - projects the settled process into the job outcome. - * @returns synchronous job hooks whose completion includes preparation and process settlement. + * @returns synchronous job hooks whose completion includes preparation and process settlement, + * and the original error of a failed preparation for a caller that reports it. */ export function processJob( start: (signal: AbortSignal) => Promise, outcome: (process: ShellProcess) => JobOutcome, -): JobHooks { +): ProcessJobHooks { const controller = new AbortController() let process: ShellProcess | undefined + let failure: { error: unknown } | undefined const done: Promise = (async () => { try { process = await start(controller.signal) @@ -112,8 +124,10 @@ export function processJob( } return outcome(process) } catch (error: unknown) { + const killed = controller.signal.aborted && process === undefined + if (!killed) failure = { error } return { - status: controller.signal.aborted && process === undefined ? 'killed' : 'failed', + status: killed ? 'killed' : 'failed', detail: error instanceof Error ? error.message : String(error), } } @@ -125,5 +139,6 @@ export function processJob( process?.kill() }, done, + failure: () => failure, } } diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index 39a2a20369..46554fc192 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -16,14 +16,16 @@ import { FiberState } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { isAbsolute, sep } from 'node:path' -import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import { defineTool, TOOL_ABORTED, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolDefinition, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { JobError } from '@deepseek-ai/dsh-jobs' import type { JobId, JobRegistry, JobView } from '@deepseek-ai/dsh-jobs' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-shell-env' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { ESCALATION_TARGETS, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import { ESCALATION_TARGETS, SandboxEscalationError, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import { subprocessSpawnFailure } from '@deepseek-ai/dsh-subprocess' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' @@ -72,13 +74,13 @@ interface BashToolArgs { function validateBashArgs(args: BashToolArgs, effectiveMode: SandboxMode | undefined): void { if (args.command.trim().length === 0) { - throw new Error('invalid command: expected a non-empty string') + throw new ToolArgsError(['invalid command: expected a non-empty string']) } if (args.description.trim().length === 0) { - throw new Error('invalid description: expected a non-empty string') + throw new ToolArgsError(['invalid description: expected a non-empty string']) } if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { - throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) + throw new ToolArgsError([`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`]) } if (args.sandbox_permissions !== undefined && args.sandbox_permissions === effectiveMode) return const justification = args.sandbox_permissions === undefined && args.justification?.trim() === '' @@ -185,11 +187,23 @@ function toolAborted(): HarnessError { return error } +/** + * Report a launch or preparation rejection as this tool's failure. A coded + * error keeps its class and code; this call's cancellation stays as thrown so + * the registry records it as aborted; any other value is classified as a + * subprocess spawn failure with the original as `cause`. + */ +function launchFailure(error: unknown, signal: AbortSignal): unknown { + return signal.aborted ? error : subprocessSpawnFailure(error, 'bash') +} + /** A registered command: its id, the process once the starter spawned it, and the reason an outside kill gave. */ interface StartedJob { readonly id: JobId readonly process: () => ShellExecution | undefined readonly stopped: () => string | undefined + /** The original error of a registration whose preparation failed before the process published. */ + readonly failure: () => { error: unknown } | undefined } /** Canonical background-handle properties shared by the bash output union. */ @@ -232,7 +246,10 @@ export function apply(ctx: Context, config: Config = {}): void { standingPolicy: SandboxExecutionPolicy | undefined, ): Promise => { if (escalationModes.length === 0) { - throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') + throw new SandboxEscalationError( + 'sandbox_permissions is not available in this composition (no sandboxing executor to escalate)', + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) } const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode return approveEscalation( @@ -266,6 +283,8 @@ export function apply(ctx: Context, config: Config = {}): void { const startJob = (registry: JobRegistry, args: BashToolArgs, exec: ToolExecution, spec: ShellExecSpec): StartedJob => { let proc: ShellExecution | undefined let stopped: string | undefined + // The registry runs the starter inside start(), so this is assigned before start() returns. + let failure!: () => { error: unknown } | undefined const id = registry.start({ kind: 'bash', label: args.command, @@ -280,6 +299,7 @@ export function apply(ctx: Context, config: Config = {}): void { }, started => processOutcome(started, escalationModes), ) + failure = () => hooks.failure() return { done: hooks.done, // A kill from outside this call (the human's, a parallel job_kill) @@ -291,7 +311,7 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) - return { id, process: () => proc, stopped: () => stopped } + return { id, process: () => proc, stopped: () => stopped, failure: () => failure() } } /** Wait on a registered foreground command until it settles or the timeout passes. */ const waitOnJob = async (registry: JobRegistry, attached: StartedJob, exec: ToolExecution, spec: ShellExecSpec) => { @@ -357,11 +377,14 @@ export function apply(ctx: Context, config: Config = {}): void { // the split streams and classification the foreground result needs. registry.remove(attached.id, owner) const process = attached.process() - // No process was published: the starter failed before the spawn, and the - // job's terminal detail is the failure the foreground projection would - // have rejected with. - if (process === undefined) throw new Error(view.detail) - const result = await process.result() + // No process was published: the starter failed before the spawn. The + // original error keeps its class and code, exactly as the foreground + // projection would have rejected; a kill before the spawn has only its detail. + if (process === undefined) { + const failure = attached.failure() + throw failure === undefined ? new Error(view.detail) : launchFailure(failure.error, exec.signal) + } + const result = await process.result().catch((error: unknown) => { throw launchFailure(error, exec.signal) }) const stopped = attached.stopped() return { kind: 'foreground' as const, ...canonicalBashResult(result, spec.workdir), ...stopped !== undefined ? { stopped } : {} } } @@ -498,10 +521,10 @@ export function apply(ctx: Context, config: Config = {}): void { if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. if (!backgroundEnabled) { - throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)') + throw new JobError('run_in_background is disabled for this deployment (enableRunInBackground: false)', 'JOB_BACKGROUND_DISABLED') } if (jobs === undefined) { - throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') + throw new ToolContextUnavailableError('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') } // The caller owns cancellation until ctx.jobs commits detached ownership. if (exec.signal.aborted) throw toolAborted() @@ -524,8 +547,12 @@ export function apply(ctx: Context, config: Config = {}): void { if (attached !== undefined) return waitOnJob(jobs, attached, exec, spec) } const spec = ctx.shell.resolve({ ...request, signal: exec.signal }) - const foreground = await ctx.shell.execute(spec) - const result = await foreground.result() + let result: ShellRunResult + try { + result = await (await ctx.shell.execute(spec)).result() + } catch (error) { + throw launchFailure(error, exec.signal) + } if (result.aborted) throw toolAborted() return { kind: 'foreground' as const, ...canonicalBashResult(result, spec.workdir) } }, diff --git a/packages/shell/tool-bash/tests/background.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts index add9714ac4..58129b3a0b 100644 --- a/packages/shell/tool-bash/tests/background.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -19,6 +19,7 @@ import type { JobId } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import type { ShellExecution, ShellProcess } from '@deepseek-ai/dsh-shell' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processSources, ringDelta } from '../src/background.ts' @@ -279,10 +280,49 @@ describe('foreground commands as jobs', () => { it('a command whose start fails reports the failure as an error result and leaves no job behind', async () => { const ctx = await setup() - vi.spyOn(ctx.shell, 'execute').mockRejectedValue(new Error('spawn bash ENOENT')) + vi.spyOn(ctx.shell, 'execute').mockRejectedValue(Object.assign(new Error('spawn bash ENOENT'), { code: 'ENOENT' })) const result = await call(ctx, { command: 'true', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + expect(text(result)).toBe('Error: cannot start "bash": the program or the working directory does not exist (spawn bash ENOENT)') + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a spawn that fails after the process published reports a coded failure and leaves no job behind', async () => { + const ctx = await setup() + const result = await call(ctx, { command: 'true', description: 'test command', workdir: join(spillDir, 'missing-workdir') }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a job stopped from outside before its process published reports the stop', async () => { + const ctx = await setup() + vi.spyOn(ctx.shell, 'execute').mockImplementation(spec => new Promise((_resolve, reject) => { + spec.signal?.addEventListener('abort', () => { reject(new Error('preparation stopped')) }, { once: true }) + })) + const pending = call(ctx, { command: 'ls', description: 'test command' }) + await vi.waitFor(() => { expect(ctx.jobs.list()).toHaveLength(1) }) + ctx.jobs.kill(ctx.jobs.list()[0]!.id, undefined, 'cancelled by the user') + const result = await pending expect(result.isError).toBe(true) - expect(text(result)).toBe('Error: spawn bash ENOENT') + expect((result.content[0] as { text: string }).text).toBe('Error: preparation stopped; cancelled by the user') + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a coded preparation failure keeps its name and code through the job path', async () => { + const ctx = await setup() + vi.spyOn(ctx.shell, 'execute').mockRejectedValue(new SandboxUnavailableError('workspace-write', 'bwrap: setting up uid map: Permission denied')) + const result = await call(ctx, { command: 'true', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SandboxUnavailableError', code: 'SANDBOX_UNAVAILABLE' } }, + }) + expect(text(result)).toContain('Runner failure: bwrap: setting up uid map: Permission denied') expect(ctx.jobs.list()).toEqual([]) }) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index d3f20572f8..554b7f4e35 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -394,7 +394,7 @@ describe('bash tool', () => { ])('rejects value-invalid args %j', async (args, pattern) => { const ctx = await setup() const result = await call(ctx, 'bash', args) - expect(result.isError).toBe(true) + expect(result).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) expect(text(result)).toMatch(pattern) }) @@ -640,7 +640,10 @@ describe('background execution through the job runtime', () => { it('fails loud when the job runtime is not loaded', async () => { const ctx = await setup() // no LocalJobRegistry / ToolJobs const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) - expect(result.isError).toBe(true) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) expect(text(result)).toContain('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') }) @@ -717,7 +720,7 @@ describe('background execution through the job runtime', () => { // Schema omission is advertising; execution must also enforce the opt-out. const forced = await call(ctx, 'bash', { command: 'echo hi', description: 'test command', run_in_background: true }) - expect(forced.isError).toBe(true) + expect(forced).toMatchObject({ isError: true, error: { info: { name: 'JobError', code: 'JOB_BACKGROUND_DISABLED' } } }) expect(text(forced)).toContain('run_in_background is disabled for this deployment') const foreground = await call(ctx, 'bash', { command: 'echo hi', description: 'test command' }) expect(foreground.isError).toBe(false) @@ -755,7 +758,10 @@ describe('sandbox escalation through the generic task producer', () => { { command: 'true', description: 'd', justification: 'why' }, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' }, ]) { - expect((await call(ctx, 'bash', args)).isError).toBe(true) + expect(await call(ctx, 'bash', args)).toMatchObject({ + isError: true, + error: { info: { name: 'SandboxEscalationError', code: 'SANDBOX_ESCALATION_INVALID_ARGUMENT' } }, + }) } }) @@ -793,7 +799,9 @@ describe('sandbox escalation through the generic task producer', () => { it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => { const plain = await setup() - expect(text(await call(plain, 'bash', escalate))).toContain('not available in this composition') + const unavailable = await call(plain, 'bash', escalate) + expect(text(unavailable)).toContain('not available in this composition') + expect(unavailable).toMatchObject({ error: { info: { name: 'SandboxEscalationError', code: 'SANDBOX_ESCALATION_UNAVAILABLE' } } }) const { ctx } = await setupSandboxed(true) const prompted = vi.fn() @@ -1543,3 +1551,29 @@ describe('resolved launch directory metadata', () => { } }) }) + +describe('launch failures without a job registry', () => { + it('classifies a platform spawn error and keeps this call\'s cancellation', async () => { + const ctx = await setup() + const missing = vi.spyOn(ctx.shell, 'execute').mockRejectedValueOnce(Object.assign(new Error('spawn bash ENOENT'), { code: 'ENOENT' })) + expect(await call(ctx, 'bash', { command: 'true', description: 'd' })).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + missing.mockRestore() + + const controller = new AbortController() + const reason = new Error('caller stopped') + vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => { + controller.abort(reason) + return Promise.reject(reason) + }) + const aborted = await executeToolFixture(ctx, { + signal: controller.signal, + callId: ToolCallId('launch-aborted'), + name: 'bash', + arguments: { command: 'true', description: 'd' }, + }) + expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } }) + }) +}) diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json index 5efa5088e1..42510cdbdd 100644 --- a/packages/shell/tool-bash/tsconfig.json +++ b/packages/shell/tool-bash/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../subprocess/subprocess" } ] } diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index ed32de4eef..8032f1b349 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -108,8 +108,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/what-the-model-sees: - en: f0802a10dbe0f7d2 - zh: 80f8b312dcfc0065 + en: d45b05d98cf6e7e3 + zh: b3c59fb3603f511e /deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/token-effect: en: 243bad012dd99c7f zh: 5ee74905020bd49a diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index 051f3cd4a2..34bc941529 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -188,7 +188,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`. +Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`). #### Token effect diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 90e2b726c1..f81c92a602 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -188,7 +188,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be #### 模型看到什么 -验证与基础设施失败统一为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。 +验证与基础设施失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。 #### Token 影响 diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 360190080e..284751c48c 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -43,7 +43,8 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -72,6 +73,7 @@ "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*", - "@deepseek-ai/dsh-fs-local": "workspace:*" + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*" } } diff --git a/packages/shell/tool-pwsh/src/background.ts b/packages/shell/tool-pwsh/src/background.ts index 67d937df79..120e595b04 100644 --- a/packages/shell/tool-pwsh/src/background.ts +++ b/packages/shell/tool-pwsh/src/background.ts @@ -11,6 +11,16 @@ import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sand import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell' import type { JobChunk, JobHooks, JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs' +/** Job hooks plus the original error of a job that failed before its process produced an outcome. */ +export interface ProcessJobHooks extends JobHooks { + /** + * The error that ended a `failed` job, kept with its own class and code; + * undefined while the job runs and for a completed or killed job. + * @returns the original failure, boxed so any thrown value is preserved. + */ + failure(): { error: unknown } | undefined +} + /* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */ /** @@ -96,14 +106,16 @@ export function ringDelta(chunks: readonly JobChunk[]): string { * Adapt asynchronous shell preparation after job admission without exposing a partial process. * @param start - starts the process with job-owned cancellation. * @param outcome - projects the settled process into the job outcome. - * @returns synchronous job hooks whose completion includes preparation and process settlement. + * @returns synchronous job hooks whose completion includes preparation and process settlement, + * and the original error of a failed preparation for a caller that reports it. */ export function processJob( start: (signal: AbortSignal) => Promise, outcome: (process: ShellProcess) => JobOutcome, -): JobHooks { +): ProcessJobHooks { const controller = new AbortController() let process: ShellProcess | undefined + let failure: { error: unknown } | undefined const done: Promise = (async () => { try { process = await start(controller.signal) @@ -114,8 +126,10 @@ export function processJob( } return outcome(process) } catch (error: unknown) { + const killed = controller.signal.aborted && process === undefined + if (!killed) failure = { error } return { - status: controller.signal.aborted && process === undefined ? 'killed' : 'failed', + status: killed ? 'killed' : 'failed', detail: error instanceof Error ? error.message : String(error), } } @@ -127,6 +141,7 @@ export function processJob( process?.kill() }, done, + failure: () => failure, } } /* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index a3472b4357..0ef0fa260b 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -25,14 +25,16 @@ import type {} from '@deepseek-ai/dsh-working-directory' import { FiberState } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import { defineTool, TOOL_ABORTED, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolDefinition, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { JobError } from '@deepseek-ai/dsh-jobs' import type { JobId, JobRegistry, JobView } from '@deepseek-ai/dsh-jobs' import type {} from '@deepseek-ai/dsh-shell-env' import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { ESCALATION_TARGETS, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import { ESCALATION_TARGETS, SandboxEscalationError, approveEscalation, sandboxPermissionsDescription, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import { subprocessSpawnFailure } from '@deepseek-ai/dsh-subprocess' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' @@ -106,13 +108,13 @@ interface PwshForegroundResult { /* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */ function validatePwshArgs(args: PwshToolArgs): void { if (args.command.trim().length === 0) { - throw new Error('invalid command: expected a non-empty string') + throw new ToolArgsError(['invalid command: expected a non-empty string']) } if (args.description.trim().length === 0) { - throw new Error('invalid description: expected a non-empty string') + throw new ToolArgsError(['invalid description: expected a non-empty string']) } if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { - throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) + throw new ToolArgsError([`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`]) } validateEscalationArgs(args.sandbox_permissions, args.justification) } @@ -193,11 +195,23 @@ function toolAborted(): HarnessError { return error } +/** + * Report a launch or preparation rejection as this tool's failure. A coded + * error keeps its class and code; this call's cancellation stays as thrown so + * the registry records it as aborted; any other value is classified as a + * subprocess spawn failure with the original as `cause`. + */ +function launchFailure(error: unknown, signal: AbortSignal): unknown { + return signal.aborted ? error : subprocessSpawnFailure(error, 'pwsh') +} + /** A registered command: its id, the process once the starter spawned it, and the reason an outside kill gave. */ interface StartedJob { readonly id: JobId readonly process: () => ShellExecution | undefined readonly stopped: () => string | undefined + /** The original error of a registration whose preparation failed before the process published. */ + readonly failure: () => { error: unknown } | undefined } /** Canonical background-handle properties shared by the pwsh output union. */ @@ -245,7 +259,10 @@ export function apply(ctx: Context, config: Config = {}): void { standingPolicy: SandboxExecutionPolicy | undefined, ): Promise => { if (escalationModes.length === 0) { - throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') + throw new SandboxEscalationError( + 'sandbox_permissions is not available in this composition (no sandboxing executor to escalate)', + 'SANDBOX_ESCALATION_UNAVAILABLE', + ) } const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode return approveEscalation( @@ -281,6 +298,8 @@ export function apply(ctx: Context, config: Config = {}): void { const startJob = (registry: JobRegistry, args: PwshToolArgs, exec: ToolExecution, spec: ShellExecSpec): StartedJob => { let proc: ShellExecution | undefined let stopped: string | undefined + // The registry runs the starter inside start(), so this is assigned before start() returns. + let failure!: () => { error: unknown } | undefined const id = registry.start({ kind: 'pwsh', label: args.command, @@ -295,6 +314,7 @@ export function apply(ctx: Context, config: Config = {}): void { }, started => processOutcome(started, escalationModes), ) + failure = () => hooks.failure() return { done: hooks.done, // A kill from outside this call (the human's, a parallel job_kill) @@ -306,7 +326,7 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) - return { id, process: () => proc, stopped: () => stopped } + return { id, process: () => proc, stopped: () => stopped, failure: () => failure() } } /** Wait on a registered foreground command until it settles or the timeout passes. */ const waitOnJob = async (registry: JobRegistry, attached: StartedJob, exec: ToolExecution, spec: ShellExecSpec) => { @@ -372,11 +392,14 @@ export function apply(ctx: Context, config: Config = {}): void { // the split streams and classification the foreground result needs. registry.remove(attached.id, owner) const process = attached.process() - // No process was published: the starter failed before the spawn, and the - // job's terminal detail is the failure the foreground projection would - // have rejected with. - if (process === undefined) throw new Error(view.detail) - const result = await process.result() + // No process was published: the starter failed before the spawn. The + // original error keeps its class and code, exactly as the foreground + // projection would have rejected; a kill before the spawn has only its detail. + if (process === undefined) { + const failure = attached.failure() + throw failure === undefined ? new Error(view.detail) : launchFailure(failure.error, exec.signal) + } + const result = await process.result().catch((error: unknown) => { throw launchFailure(error, exec.signal) }) const stopped = attached.stopped() return { ...canonicalPwshResult(result, spec.workdir), ...stopped !== undefined ? { stopped } : {} } } @@ -521,10 +544,10 @@ export function apply(ctx: Context, config: Config = {}): void { if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. if (!backgroundEnabled) { - throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)') + throw new JobError('run_in_background is disabled for this deployment (enableRunInBackground: false)', 'JOB_BACKGROUND_DISABLED') } if (jobs === undefined) { - throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') + throw new ToolContextUnavailableError('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') } // The caller owns cancellation until ctx.jobs commits detached ownership. if (exec.signal.aborted) throw toolAborted() @@ -547,8 +570,12 @@ export function apply(ctx: Context, config: Config = {}): void { if (attached !== undefined) return waitOnJob(jobs, attached, exec, spec) } const spec = ctx.shell.resolve({ ...request, signal: exec.signal }) - const foreground = await ctx.shell.execute(spec) - const result = await foreground.result() + let result: ShellRunResult + try { + result = await (await ctx.shell.execute(spec)).result() + } catch (error) { + throw launchFailure(error, exec.signal) + } if (result.aborted) throw toolAborted() return canonicalPwshResult(result, spec.workdir) }, diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 2794df944a..48b01dde16 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -13,6 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { unsupportedModel, provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' +import { SandboxPreparationError } from '@deepseek-ai/dsh-sandbox' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess } from '@deepseek-ai/dsh-shell' import { renderPwshPromoted } from '../src/render.ts' @@ -346,8 +347,56 @@ describe('foreground commands as jobs (pwsh)', () => { const { ctx, pwsh } = await setup() pwsh.backgroundHandler = () => { throw new Error('spawn pwsh ENOENT') } const result = await call(ctx, { command: 'Get-Missing', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_FAILED' } }, + }) + expect((result.content[0] as { text: string }).text).toBe('Error: cannot start "pwsh": spawn pwsh ENOENT') + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a spawn that fails after the process published reports a coded failure', async () => { + const { ctx, pwsh } = await setup() + pwsh.backgroundHandler = () => { + const started = observableProcess() + started.finish() + return Object.assign(started.proc, { + result: () => Promise.reject(Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT' })), + }) + } + const result = await call(ctx, { command: 'Get-ChildItem', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a job stopped from outside before its process published reports the stop', async () => { + const { ctx, pwsh } = await setup() + vi.spyOn(pwsh, 'execute').mockImplementation(spec => new Promise((_resolve, reject) => { + spec.signal?.addEventListener('abort', () => { reject(new Error('preparation stopped')) }, { once: true }) + })) + const pending = call(ctx, { command: 'Get-ChildItem', description: 'test command' }) + await vi.waitFor(() => { expect(ctx.jobs.list()).toHaveLength(1) }) + ctx.jobs.kill(ctx.jobs.list()[0]!.id, undefined, 'cancelled by the user') + const result = await pending expect(result.isError).toBe(true) - expect((result.content[0] as { text: string }).text).toBe('Error: spawn pwsh ENOENT') + expect((result.content[0] as { text: string }).text).toBe('Error: preparation stopped; cancelled by the user') + expect(ctx.jobs.list()).toEqual([]) + }) + + it('a Windows ACL preparation failure keeps its name and code through the job path', async () => { + const { ctx, pwsh } = await setup() + const cause = new Error('SetNamedSecurityInfoW failed (Win32 5): C:\\ws') + pwsh.backgroundHandler = () => { + throw new SandboxPreparationError('sandbox-local: windows-acl workspace grant failed', 'SANDBOX_PREPARATION_FAILED', { cause }) + } + const result = await call(ctx, { command: 'Get-ChildItem', description: 'test command' }) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED' } }, + }) expect(ctx.jobs.list()).toEqual([]) }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 731da47a4c..00fb01724f 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -434,6 +434,13 @@ describe('argument validation', () => { expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string') expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 }))) .toContain('invalid timeoutMs: expected a positive number') + for (const args of [ + { command: ' ', description: 'd' }, + { command: 'Write-Output hi', description: ' ' }, + { command: 'Write-Output hi', description: 'd', timeoutMs: -1 }, + ]) { + expect(await call(ctx, 'pwsh', args)).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) + } }) }) @@ -676,7 +683,9 @@ describe('sandbox escalation through ctx.approval', () => { it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => { const plain = await setup() - expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition') + const unavailable = await call(plain.ctx, 'pwsh', escalate) + expect(text(unavailable)).toContain('not available in this composition') + expect(unavailable).toMatchObject({ error: { info: { name: 'SandboxEscalationError', code: 'SANDBOX_ESCALATION_UNAVAILABLE' } } }) const { ctx } = await setupSandboxed(true) const prompted = vi.fn() @@ -861,7 +870,10 @@ describe('background execution through the job runtime', () => { it('fails loud when the job runtime is not loaded', async () => { const { ctx } = await setup() // no LocalJobRegistry / ToolJobs const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) - expect(result.isError).toBe(true) + expect(result).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) expect(text(result)).toContain('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') }) @@ -912,7 +924,7 @@ describe('background execution through the job runtime', () => { // Schema omission is advertising; execution must also enforce the opt-out. const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true }) - expect(forced.isError).toBe(true) + expect(forced).toMatchObject({ isError: true, error: { info: { name: 'JobError', code: 'JOB_BACKGROUND_DISABLED' } } }) expect(text(forced)).toContain('run_in_background is disabled for this deployment') const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' }) expect(foreground.isError).toBe(false) @@ -1280,3 +1292,29 @@ describe('resolved launch directory metadata', () => { } }) }) + +describe('launch failures without a job registry', () => { + it('classifies a platform spawn error and keeps this call\'s cancellation', async () => { + const { ctx } = await setup() + const missing = vi.spyOn(ctx.shell, 'execute').mockRejectedValueOnce(Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT' })) + expect(await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'd' })).toMatchObject({ + isError: true, + error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } }, + }) + missing.mockRestore() + + const controller = new AbortController() + const reason = new Error('caller stopped') + vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => { + controller.abort(reason) + return Promise.reject(reason) + }) + const aborted = await executeToolFixture(ctx, { + signal: controller.signal, + callId: ToolCallId('launch-aborted'), + name: 'pwsh', + arguments: { command: 'Write-Output ok', description: 'd' }, + }) + expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } }) + }) +}) diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json index 80a56fcda6..4015c265db 100644 --- a/packages/shell/tool-pwsh/tsconfig.json +++ b/packages/shell/tool-pwsh/tsconfig.json @@ -52,6 +52,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../subprocess/subprocess" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90d655e2af..4b81d0ed5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15586,6 +15586,9 @@ importers: '@deepseek-ai/dsh-shell-env': specifier: workspace:* version: link:../shell-env + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../../subprocess/subprocess-local @@ -15735,6 +15738,9 @@ importers: '@deepseek-ai/dsh-shell-env': specifier: workspace:* version: link:../shell-env + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../../subprocess/subprocess-local From 3538757cbea0decda47b20d34f7c28bdf17fa6ca Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:35:58 +0800 Subject: [PATCH 19/81] feat(terminal): code PTY session and persistent shell failures TerminalError gains SESSION_CLOSING, SESSION_EXITED, STARTUP_EXITED, STARTUP_TIMEOUT, NO_SANDBOX, and INIT_REJECTED, following its existing unprefixed code style. The terminal service and terminal-bash use them for a send to a closing or exited session, a shell that exits or misses readiness during startup, and a confined mode without a sandbox provider; an empty confined argv is SubprocessError SUBPROCESS_INVALID_ARGV, and a send on an aborted signal throws an error named AbortError. The persistent bash and pwsh tools report a missing owner as ToolContextUnavailableError, a blank command as ToolArgsError, and a shell that rejects its setup as TerminalError INIT_REJECTED. Refs #7869 --- .../tool-bash-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-bash-persistent/README.md | 2 +- .../shell/tool-bash-persistent/README.zh.md | 2 +- .../shell/tool-bash-persistent/src/index.ts | 9 +++++---- .../tool-bash-persistent/tests/tools.spec.ts | 14 +++++++++++--- .../tool-pwsh-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-pwsh-persistent/README.md | 2 +- .../shell/tool-pwsh-persistent/README.zh.md | 2 +- .../shell/tool-pwsh-persistent/src/index.ts | 6 +++--- .../tool-pwsh-persistent/tests/tools.spec.ts | 9 +++++++-- packages/terminal/terminal-bash/src/index.ts | 18 ++++++++++++------ packages/terminal/terminal-bash/src/session.ts | 17 +++++++++++------ .../terminal/terminal-bash/tests/index.spec.ts | 4 ++++ .../terminal-bash/tests/session.spec.ts | 11 ++++++++--- packages/terminal/terminal/README.i18n.yaml | 4 ++-- packages/terminal/terminal/README.md | 2 +- packages/terminal/terminal/README.zh.md | 2 +- packages/terminal/terminal/src/index.ts | 17 +++++++++++++++-- .../terminal/terminal/tests/service.spec.ts | 2 ++ 19 files changed, 90 insertions(+), 41 deletions(-) diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml index cc6225da8c..a7d7a29c7c 100644 --- a/packages/shell/tool-bash-persistent/README.i18n.yaml +++ b/packages/shell/tool-bash-persistent/README.i18n.yaml @@ -27,8 +27,8 @@ en: b398f124ea7b77b0 zh: 50855e601e6c1b88 /deepseek-ai-dsh-tool-bash-persistent/use-this-package/what-can-go-wrong: - en: 2713550ebdb02f69 - zh: 52ed398dc541d1fc + en: 52e0e1489c6f8071 + zh: 27251d61351ee72d /deepseek-ai-dsh-tool-bash-persistent/understand-the-implementation: en: 8d94a7381bca3fa9 zh: f4bd2866fff6fa8d diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md index 4bbea6bf1a..9feed7a656 100644 --- a/packages/shell/tool-bash-persistent/README.md +++ b/packages/shell/tool-bash-persistent/README.md @@ -58,7 +58,7 @@ Commands share one shell per agent, so state persists until an `exit`, a timeout ### What can go wrong -A call without an owning agent session fails with `bash requires an owning agent session`, and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. +A call without an owning agent session fails with `bash requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. ----- diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md index 6b4877e177..c348502a09 100644 --- a/packages/shell/tool-bash-persistent/README.zh.md +++ b/packages/shell/tool-bash-persistent/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" ### 可能出什么问题 -没有拥有者 agent 会话的调用会以 `bash requires an owning agent session` 失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 +没有拥有者 agent 会话的调用会以 `bash requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 ----- diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 94de7ccf09..ac8d11ef77 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -9,9 +9,10 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' +import { TerminalError } from '@deepseek-ai/dsh-terminal' import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' // TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. 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.' @@ -278,7 +279,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell }) const result = await setup.done if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { - throw new Error('persistent bash shell did not accept initialization') + throw new TerminalError('persistent bash shell did not accept initialization', 'INIT_REJECTED') } return spawned.sessionId } catch (error: unknown) { @@ -428,9 +429,9 @@ function registerPersistentBash(ctx: Context, config: ResolvedConfig): void { render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args, exec) { - if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + if (args.command.trim().length === 0) throw new ToolArgsError(['command must be a non-empty string']) const owner = exec.agent - if (owner === undefined) throw new Error('bash requires an owning agent session') + if (owner === undefined) throw new ToolContextUnavailableError('bash requires an owning agent session') return serialized(owner, async () => { if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement. return executeCommand(ctx, shells, owner, args.command, config, exec.signal) diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 7a926e670e..6793099643 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -694,7 +694,10 @@ describe('tool-bash-persistent', () => { 'fails initialization and closes the unusable shell for %s', async (mode) => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) - expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(await call(ctx, owner, 'pwd')).toMatchObject({ + isError: true, + error: { info: { name: 'TerminalError', code: 'INIT_REJECTED' } }, + }) expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed') }, ) @@ -752,8 +755,13 @@ describe('tool-bash-persistent', () => { it('rejects invalid config and invalid calls', async () => { const { ctx, owner, stub } = await setup() - expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) - expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + expect(await call(ctx, undefined, 'pwd')).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) + const blank = await call(ctx, owner, ' ') + expect(text(blank)).toContain('command must be a non-empty string') + expect(blank).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) const controller = new AbortController() controller.abort(new Error('caller stopped')) diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml index 60b8ae7682..ab2bb8cd7f 100644 --- a/packages/shell/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/README.i18n.yaml @@ -27,8 +27,8 @@ en: 9f4c22b06f940caf zh: 61f755cec7ff5b76 /deepseek-ai-dsh-tool-pwsh-persistent/use-this-package/what-can-go-wrong: - en: 70bf2e1e8d8ccbf6 - zh: 6c78d8c85113adf7 + en: 3abc9cc73411c042 + zh: 31c9e3ddf7160414 /deepseek-ai-dsh-tool-pwsh-persistent/understand-the-implementation: en: 8d94a7381bca3fa9 zh: f4bd2866fff6fa8d diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md index 0bd03285be..5437ef8a9a 100644 --- a/packages/shell/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -60,7 +60,7 @@ Commands share one shell per agent, so cwd, `$env:` variables, functions, and ba ### What can go wrong -A call without an owning agent session fails with `pwsh requires an owning agent session`, and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout closes the uncertain shell and reports bounded partial output and the reset. Cancellation resets the shell and discards command output. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. +A call without an owning agent session fails with `pwsh requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout closes the uncertain shell and reports bounded partial output and the reset. Cancellation resets the shell and discards command output. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. ----- diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md index 7f05af221f..f249622ce2 100644 --- a/packages/shell/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -60,7 +60,7 @@ kind: "package-reference" ### 可能出什么问题 -没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session` 失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时关闭不确定的 shell,并报告受限的部分输出与重置。取消会重置 shell 并丢弃命令输出。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 +没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时关闭不确定的 shell,并报告受限的部分输出与重置。取消会重置 shell 并丢弃命令输出。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 ----- diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index 132ca51d2c..69c2ef9990 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -13,7 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' // TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. 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 Select-String in order to find the line numbers of what you are looking for.' @@ -439,9 +439,9 @@ function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args, exec) { - if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + if (args.command.trim().length === 0) throw new ToolArgsError(['command must be a non-empty string']) const owner = exec.agent - if (owner === undefined) throw new Error('pwsh requires an owning agent session') + if (owner === undefined) throw new ToolContextUnavailableError('pwsh requires an owning agent session') return serialized(owner, async () => { if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement. return executeCommand(ctx, shells, owner, args.command, config, exec.signal) diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index f842613039..c22cad7974 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -829,8 +829,13 @@ describe('tool-pwsh-persistent', () => { it('rejects invalid config and invalid calls', async () => { const { ctx, owner, stub } = await setup() - expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) - expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + expect(await call(ctx, undefined, 'pwd')).toMatchObject({ + isError: true, + error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } }, + }) + const blank = await call(ctx, owner, ' ') + expect(text(blank)).toContain('command must be a non-empty string') + expect(blank).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } }) const controller = new AbortController() controller.abort(new Error('caller stopped')) diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 5359948eb6..bdb60aa177 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -8,8 +8,9 @@ import type {} from '@deepseek-ai/dsh-working-directory' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal' +import { TerminalBackendCleanupError, TerminalError } from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSpawnSpec, TerminalSendOperation } from '@deepseek-ai/dsh-terminal' +import { SubprocessError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' @@ -94,12 +95,17 @@ function childEnvironment(spec: TerminalBackendSpawnSpec, dialect: ShellDialect) export const PWSH_PROMPT_SETUP = "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }" +/** The startup deadline failure shared by both dialects. */ +function startupTimeout(): TerminalError { + return new TerminalError('PTY shell did not reach readiness before startup timeout', 'STARTUP_TIMEOUT') +} + async function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy, signal?: AbortSignal): Promise { const argv = [config.shellPath, ...config.shellArgs] if (policy.mode === 'danger-full-access') return argv const sandbox = ctx.get('sandbox') if (sandbox === undefined) { - throw new Error(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`) + throw new TerminalError(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`, 'NO_SANDBOX') } // Re-state the discriminant because object spread does not preserve its narrowed type. return (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv @@ -134,8 +140,8 @@ async function startupSession( ...signal !== undefined ? { signal } : {}, }) const result = await startupOperation.done - if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') - if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + if (result.waitReason === 'session_exit') throw new TerminalError('PTY shell exited during startup', 'STARTUP_EXITED') + if (result.waitReason === 'timeout') throw startupTimeout() viewport = result.viewport if (result.waitReason === 'stdin_read') break } @@ -154,7 +160,7 @@ async function startupSession( const deadline = Promise.withResolvers() deadlineTimer = setTimeout(() => { startupOperation?.cancel() - deadline.reject(new Error('PTY shell did not reach readiness before startup timeout')) + deadline.reject(startupTimeout()) }, timeoutMs) races.push(deadline.promise) } @@ -201,7 +207,7 @@ export class BashTerminalBackend implements TerminalBackend { const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session }) const argv = await spawnArgv(this.ctx, this.config, policy, spec.signal) spec.signal?.throwIfAborted() - if (argv[0] === undefined) throw new Error('terminal-bash: sandbox returned empty argv') + if (argv[0] === undefined) throw new SubprocessError('terminal-bash: sandbox returned empty argv', 'SUBPROCESS_INVALID_ARGV') const terminal = await this.spawnTerminal({ argv, cwd: spec.cwd ?? await this.ctx.workingDirectory.ensure(spec.owner, spec.signal), diff --git a/packages/terminal/terminal-bash/src/session.ts b/packages/terminal/terminal-bash/src/session.ts index 993b15c4ee..a78c6e4f7c 100644 --- a/packages/terminal/terminal-bash/src/session.ts +++ b/packages/terminal/terminal-bash/src/session.ts @@ -314,8 +314,8 @@ export class LocalPtySession implements TerminalBackendSession { try { const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} }) const result = await operation.done - if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') - if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + if (result.waitReason === 'session_exit') throw new TerminalError('PTY shell exited during startup', 'STARTUP_EXITED') + if (result.waitReason === 'timeout') throw new TerminalError('PTY shell did not reach readiness before startup timeout', 'STARTUP_TIMEOUT') this.motd = result.viewport } catch (error: unknown) { signal?.throwIfAborted() @@ -326,8 +326,8 @@ export class LocalPtySession implements TerminalBackendSession { } startSend(request: TerminalSendRequest): TerminalSendOperation { - if (this.closing) throw new Error('PTY session is closing') - if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited') + if (this.closing) throw new TerminalError('PTY session is closing', 'SESSION_CLOSING') + if (this.statusValue.kind === 'exited') throw new TerminalError('PTY session has exited', 'SESSION_EXITED') if (this.active !== undefined) { const draining = this.activeWrite !== undefined ? ' or draining provider write' @@ -336,7 +336,12 @@ export class LocalPtySession implements TerminalBackendSession { : '' throw new TerminalError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE') } - if (request.signal?.aborted === true) throw new Error('PTY send aborted before write') + if (request.signal?.aborted === true) { + // Named as a cancellation so the tool registry classifies it as the caller's abort. + const aborted = new Error('PTY send aborted before write') + aborted.name = 'AbortError' + throw aborted + } const operation = new LocalSendOperation( this.config.maxReadBytes, @@ -452,7 +457,7 @@ export class LocalPtySession implements TerminalBackendSession { } async signal(signal: TerminalSignal): Promise { - if (this.closing) throw new Error('PTY session is closing') + if (this.closing) throw new TerminalError('PTY session is closing', 'SESSION_CLOSING') const targetPgid = await this.terminal.signalForeground(signal) return { delivered: true, targetPgid } } diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 71793a5f6e..d992f8ee7f 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -133,6 +133,7 @@ describe('BashTerminalBackend startup rollback', () => { controller.abort(abortReason) await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason) await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv') + await expect(backend.spawn(spec(agent(ctx)))).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_INVALID_ARGV' }) }) it('closes failed startup and aggregates cleanup failure', async () => { @@ -347,6 +348,7 @@ describe('BashTerminalBackend startup rollback', () => { await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toThrow( 'sandbox mode "workspace-write" requires a ctx.sandbox provider in the execution world', ) + await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'NO_SANDBOX' })) }) it('forwards terminal allocation cancellation directly', async () => { @@ -523,8 +525,10 @@ describe('BashTerminalBackend startup rollback', () => { }) as unknown as LocalPtySession const exited = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit')) await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup') + await expect(exited.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_EXITED' })) const timedOut = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') + await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_TIMEOUT' })) }) it('bounds all pwsh startup retries with one deadline', async () => { diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index bff1317c26..4dab23a32e 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -752,6 +752,7 @@ describe('LocalPtySession readiness and output', () => { terminal.emitExit(7, 9) expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGKILL' } }) expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited') + expect(() => session.startSend({ text: '', submit: false })).toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_EXITED' })) }) it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => { @@ -776,6 +777,7 @@ describe('LocalPtySession readiness and output', () => { const aborted = new AbortController() aborted.abort() expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write') + expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow(expect.objectContaining({ name: 'AbortError' })) terminal.throwWrite = true const failed = session.startSend({ text: 'x', submit: false }) @@ -1166,6 +1168,7 @@ describe('LocalPtySession readiness and output', () => { const initializing = startup.initialize(new AbortController().signal) startupTerminal.emitExit(1) await expect(initializing).rejects.toThrow('exited during startup') + await expect(initializing).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_EXITED' })) expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null }) const terminal = new FakeTerminal() @@ -1238,7 +1241,9 @@ describe('LocalPtySession readiness and output', () => { const timeoutTerminal = new FakeTerminal() const timeout = new LocalPtySession(timeoutTerminal, config()) - const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout') + const timedOut = expect(timeout.initialize()).rejects.toEqual(expect.objectContaining({ + name: 'TerminalError', code: 'STARTUP_TIMEOUT', message: expect.stringContaining('startup timeout') as string, + })) await vi.advanceTimersByTimeAsync(100) await timedOut }) @@ -1640,8 +1645,8 @@ describe('LocalPtySession bounds, signals, and teardown', () => { const closing = session.close('test') expect(session.close('other')).toBe(closing) await expect(closing).rejects.toThrow('PTY cleanup failed (test)') - expect(() => session.startSend({ text: '', submit: false })).toThrow('closing') - await expect(session.signal('SIGTERM')).rejects.toThrow('closing') + expect(() => session.startSend({ text: '', submit: false })).toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' })) + await expect(session.signal('SIGTERM')).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' })) }) it('reports cleanup failure without waiting for top-level exit and permits retry', async () => { diff --git a/packages/terminal/terminal/README.i18n.yaml b/packages/terminal/terminal/README.i18n.yaml index 50ce7993cf..6b91667f72 100644 --- a/packages/terminal/terminal/README.i18n.yaml +++ b/packages/terminal/terminal/README.i18n.yaml @@ -48,8 +48,8 @@ en: 7af36e58919e41d5 zh: 4fe7b4db1099bcbf /deepseek-ai-dsh-terminal/understand-the-implementation/send-reservation: - en: 5815723cd0c6089c - zh: 0c612ffa4b8c466c + en: 0b8dc3a382aa7bf2 + zh: e6cc6906e01827d2 /deepseek-ai-dsh-terminal/further-exploration: en: 8459a3b70a9f8bad zh: 21372aa8939d2182 diff --git a/packages/terminal/terminal/README.md b/packages/terminal/terminal/README.md index 26e8506bd8..40cb6866f8 100644 --- a/packages/terminal/terminal/README.md +++ b/packages/terminal/terminal/README.md @@ -88,7 +88,7 @@ Each published session is a record of its id, owner, optional name, backend type ### Send reservation -The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. +The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. Failures are `TerminalError` instances (a `HarnessError`); besides the registry codes, a send to a closing session fails with `SESSION_CLOSING`, and backends report `SESSION_EXITED`, `STARTUP_EXITED`, `STARTUP_TIMEOUT`, `NO_SANDBOX`, and `INIT_REJECTED` with the same class.
diff --git a/packages/terminal/terminal/README.zh.md b/packages/terminal/terminal/README.zh.md index a185554a9e..bb21c9e502 100644 --- a/packages/terminal/terminal/README.zh.md +++ b/packages/terminal/terminal/README.zh.md @@ -88,7 +88,7 @@ kind: "package-reference" ### 发送预留 -服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。 +服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。失败均为 `TerminalError`(一种 `HarnessError`);除注册表 code 外,向正在关闭的会话发送会以 `SESSION_CLOSING` 失败,后端以同一类报告 `SESSION_EXITED`、`STARTUP_EXITED`、`STARTUP_TIMEOUT`、`NO_SANDBOX` 与 `INIT_REJECTED`。
diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index f97ab51cae..df09ff94aa 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -52,16 +52,29 @@ declare module '@deepseek-ai/cordis' { } } -/** Machine-routable PTY service failures. */ +/** + * Machine-routable PTY service and backend failures. Backends use + * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a + * confined mode has no sandbox provider, `SESSION_CLOSING` and + * `SESSION_EXITED` for an operation on a closing or exited session, and + * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses + * readiness during startup. + */ export type TerminalErrorCode = | 'DUPLICATE_BACKEND' | 'DUPLICATE_NAME' | 'FOREIGN_SESSION' + | 'INIT_REJECTED' | 'NO_BACKEND' + | 'NO_SANDBOX' | 'NO_SESSION' | 'OWNER_NOT_LIVE' | 'SEND_ACTIVE' | 'SERVICE_DISPOSING' + | 'SESSION_CLOSING' + | 'SESSION_EXITED' + | 'STARTUP_EXITED' + | 'STARTUP_TIMEOUT' /** Harness error carrying a stable {@link TerminalErrorCode}. */ export class TerminalError extends HarnessError { @@ -246,7 +259,7 @@ export class TerminalSessionService extends Service { */ startSend(owner: Agent, id: TerminalSessionId, request: TerminalSendRequest): TerminalSendOperation { const record = this.expectOwned(owner, id) - if (record.closing !== undefined) throw new Error(`PTY session ${id} is closing`) + if (record.closing !== undefined) throw new TerminalError(`PTY session ${id} is closing`, 'SESSION_CLOSING') if (record.active !== undefined) throw new TerminalError(`PTY session ${id} already has an active send`, 'SEND_ACTIVE') const operation = record.session.startSend(request) record.active = operation diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 3d8304275b..0237f2970a 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -525,6 +525,8 @@ describe('TerminalSessionService ownership and lifecycle', () => { b.sessions[0]!.closeGate = Promise.withResolvers() const first = ctx.terminals.kill(owner, created.sessionId) expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: '', submit: false })).toThrow('closing') + expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: '', submit: false })) + .toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' })) const second = ctx.terminals.kill(owner, created.sessionId) b.sessions[0]!.closeGate?.resolve(undefined) expect(await first).toBe(true) From 818b603ed4ea8128e2c2b3cd346502758780da5d Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:38:23 +0800 Subject: [PATCH 20/81] feat(shell-env): add ShellEnvError for invalid contributions A contributor that returns an undeclared key or a non-string value now fails the shell call with ShellEnvError SHELL_ENV_CONTRIBUTION_INVALID instead of a plain Error, with the same message. Refs #7869 --- packages/shell/shell-env/README.i18n.yaml | 4 +-- packages/shell/shell-env/README.md | 2 +- packages/shell/shell-env/README.zh.md | 2 +- packages/shell/shell-env/package.json | 3 +- packages/shell/shell-env/src/index.ts | 33 +++++++++++++++++-- .../shell/shell-env/tests/shell-env.spec.ts | 2 ++ packages/shell/shell-env/tsconfig.json | 3 ++ 7 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/shell/shell-env/README.i18n.yaml b/packages/shell/shell-env/README.i18n.yaml index 63f09470f2..eac66fcb65 100644 --- a/packages/shell/shell-env/README.i18n.yaml +++ b/packages/shell/shell-env/README.i18n.yaml @@ -21,8 +21,8 @@ en: bab0b75fb967d41a zh: 8f636d9965d01ad1 /deepseek-ai-dsh-shell-env/use-this-package/adding-your-own-environment-facts: - en: 21ef223626e35905 - zh: 5c468055099b0135 + en: 14da34f853d089be + zh: 5ad3942aa5321893 /deepseek-ai-dsh-shell-env/use-this-package/choosing-the-harness-home: en: 0041438f2b7f7e4f zh: 93fa165b07adcdf4 diff --git a/packages/shell/shell-env/README.md b/packages/shell/shell-env/README.md index c2507b3c7c..86037caffe 100644 --- a/packages/shell/shell-env/README.md +++ b/packages/shell/shell-env/README.md @@ -50,7 +50,7 @@ export function apply(ctx: Context): void { } ``` -Contributors must declare every key they return; returning an undeclared or non-string value fails the call. Registration is disposed with the registering plugin, so hot-reloading a plugin removes its facts. +Contributors must declare every key they return; returning an undeclared or non-string value fails the call with `ShellEnvError` (`SHELL_ENV_CONTRIBUTION_INVALID`). Registration is disposed with the registering plugin, so hot-reloading a plugin removes its facts. ### Choosing the Harness home diff --git a/packages/shell/shell-env/README.zh.md b/packages/shell/shell-env/README.zh.md index 8860c11b54..a3d4a98516 100644 --- a/packages/shell/shell-env/README.zh.md +++ b/packages/shell/shell-env/README.zh.md @@ -50,7 +50,7 @@ export function apply(ctx: Context): void { } ``` -contributor 必须声明它返回的每个键;返回未声明或非字符串的值会让该次调用失败。注册随注册插件的释放而释放,因此热重载插件会移除它的事实。 +contributor 必须声明它返回的每个键;返回未声明或非字符串的值会让该次调用以 `ShellEnvError`(`SHELL_ENV_CONTRIBUTION_INVALID`)失败。注册随注册插件的释放而释放,因此热重载插件会移除它的事实。 ### 选择 Harness 主目录 diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 4fb761f034..4fb7d7de4f 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-shell": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/shell/shell-env/src/index.ts b/packages/shell/shell-env/src/index.ts index d53a393a8d..aa3012e2b4 100644 --- a/packages/shell/shell-env/src/index.ts +++ b/packages/shell/shell-env/src/index.ts @@ -14,6 +14,7 @@ import z from '@deepseek-ai/schemastery' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-shell' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' // Declares `Context.profileContext`, the launcher-provided profile the built-ins read. import type {} from '@deepseek-ai/dsh-app-boot' @@ -24,6 +25,31 @@ declare module '@deepseek-ai/cordis' { } } +/** + * Every {@link ShellEnvError} code: + * - `SHELL_ENV_CONTRIBUTION_INVALID` — a registered contributor returned an undeclared key or a non-string value; the + * shell command does not run, and the contributing plugin must be fixed. + */ +export const SHELL_ENV_ERROR_CODES = ['SHELL_ENV_CONTRIBUTION_INVALID'] as const + +/** One code from {@link SHELL_ENV_ERROR_CODES}. */ +export type ShellEnvErrorCode = typeof SHELL_ENV_ERROR_CODES[number] + +/** Shell environment failure carrying a stable {@link ShellEnvErrorCode}. */ +export class ShellEnvError extends HarnessError { + override readonly code: ShellEnvErrorCode + + /** + * @param message - names the contributor and the offending key. + * @param code - the failure class consumers route on. + */ + constructor(message: string, code: ShellEnvErrorCode) { + super(message, code) + this.name = 'ShellEnvError' + this.code = code + } +} + export const name = 'shell-env' export const inject: string[] = [] @@ -182,10 +208,13 @@ export class ShellEnvRegistry extends Service { for (const [rawKey, value] of Object.entries(resolved)) { const key = rawKey as DshEnvironmentKey if (!Object.hasOwn(contributor.variables, key)) { - throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) + throw new ShellEnvError(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`, 'SHELL_ENV_CONTRIBUTION_INVALID') } if (typeof value !== 'string') { - throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) + throw new ShellEnvError( + `bash env contributor "${contributor.name}" returned a non-string value for "${key}"`, + 'SHELL_ENV_CONTRIBUTION_INVALID', + ) } values[key] = value } diff --git a/packages/shell/shell-env/tests/shell-env.spec.ts b/packages/shell/shell-env/tests/shell-env.spec.ts index e14d41b06c..74590aa42e 100644 --- a/packages/shell/shell-env/tests/shell-env.spec.ts +++ b/packages/shell/shell-env/tests/shell-env.spec.ts @@ -192,6 +192,7 @@ describe('ShellEnvRegistry', () => { }) expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/) + expect(() => registry.collect(execution())).toThrow(expect.objectContaining({ name: 'ShellEnvError', code: 'SHELL_ENV_CONTRIBUTION_INVALID' })) }) it('rejects non-string values returned by a contributor', () => { @@ -203,6 +204,7 @@ describe('ShellEnvRegistry', () => { }) expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/) + expect(() => registry.collect(execution())).toThrow(expect.objectContaining({ name: 'ShellEnvError', code: 'SHELL_ENV_CONTRIBUTION_INVALID' })) }) it('removes an effect-scoped contributor when its plugin is disposed', async () => { diff --git a/packages/shell/shell-env/tsconfig.json b/packages/shell/shell-env/tsconfig.json index fc6a34b571..ae66c37da9 100644 --- a/packages/shell/shell-env/tsconfig.json +++ b/packages/shell/shell-env/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../boot/app-boot" + }, + { + "path": "../../llm/llm" } ] } From 529077d109a444b14158f9f98d1917e1fa70bd92 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:48:50 +0800 Subject: [PATCH 21/81] test(snapshots): record structured errors for shell, job, and escalation failures Refreshed replays now persist error.name and error.code on three tool results that previously carried only text: the rejected escalation (SandboxEscalationError SANDBOX_ESCALATION_REJECTED), the background job limit (JobError JOB_LIMIT_REACHED), and the unknown job id after a missing sandbox runner (JobError JOB_NOT_FOUND). Model-visible content is unchanged. Refs #7869 --- snapshots/acp/escalation-rejected/session.v4.jsonl | 2 +- snapshots/session/background-job-admission/session.v4.jsonl | 2 +- snapshots/session/missing-sandbox-runner/session.v4.jsonl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/snapshots/acp/escalation-rejected/session.v4.jsonl b/snapshots/acp/escalation-rejected/session.v4.jsonl index e0eabb8fe3..434c7bcf86 100644 --- a/snapshots/acp/escalation-rejected/session.v4.jsonl +++ b/snapshots/acp/escalation-rejected/session.v4.jsonl @@ -16,7 +16,7 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} {"type":"approval/asked","data":{"id":"{{approval:1}}","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} {"type":"approval/decided","data":{"id":"{{approval:1}}","outcome":"rejected"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912"},"toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\"; it stays denied, so stop and explain instead of working around it"}],"isError":true,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912"},"toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\"; it stays denied, so stop and explain instead of working around it"}],"isError":true,"id":"{{message:5}}"},"error":{"name":"SandboxEscalationError","code":"SANDBOX_ESCALATION_REJECTED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30},"stream":[{"type":"chunk","time":1788699392206,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699392206,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," rejected"," the"," escalation","."," As"," instructed",","," I"," will"," not"," ret","ry"," and"," will"," not"," work"," around"," it"," —"," just"," explain"," in"," one"," short"," sentence"," and"," stop","."]},{"type":"chunk","time":1788699392207,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699392207,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," rejected"," the"," permission"," escalation",","," so"," this"," command"," cannot"," be"," run","."]},{"type":"chunk","time":1788699392207,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}},{"type":"chunk","time":1788699392207,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}},{"type":"chunk","time":1788699392207,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}},{"type":"chunk","time":1788699392207,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/background-job-admission/session.v4.jsonl b/snapshots/session/background-job-admission/session.v4.jsonl index 161b12144f..4c37185c96 100644 --- a/snapshots/session/background-job-admission/session.v4.jsonl +++ b/snapshots/session/background-job-admission/session.v4.jsonl @@ -20,7 +20,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791427794157,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791427794157,"index":0,"dt":[],"id":"bounded-task-second","name":"bash","args":["{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"]},{"type":"chunk","time":1791427794157,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"}}},{"type":"chunk","time":1791427794157,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791427794157,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"bounded-task-second"},"toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background job limit reached for this owner (limit: 1); use job_kill to stop an unneeded job, wait for it to finish, then retry"}],"isError":true,"id":"{{message:7}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"bounded-task-second"},"toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background job limit reached for this owner (limit: 1); use job_kill to stop an unneeded job, wait for it to finish, then retry"}],"isError":true,"id":"{{message:7}}"},"error":{"name":"JobError","code":"JOB_LIMIT_REACHED"}},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-kill","name":"job_kill","arguments":"{\"job_id\":\"{{id:1}}\",\"reason\":\"free the bounded task slot\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791427794174,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791427794174,"index":0,"dt":[],"id":"bounded-task-kill","name":"job_kill","args":["{\"job_id\":\"{{id:1}}\",\"reason\":\"free the bounded task slot\"}"]},{"type":"chunk","time":1791427794174,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-kill","name":"job_kill","arguments":"{\"job_id\":\"{{id:1}}\",\"reason\":\"free the bounded task slot\"}"}}},{"type":"chunk","time":1791427794174,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791427794174,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/missing-sandbox-runner/session.v4.jsonl b/snapshots/session/missing-sandbox-runner/session.v4.jsonl index a6bc8b460e..7922555084 100644 --- a/snapshots/session/missing-sandbox-runner/session.v4.jsonl +++ b/snapshots/session/missing-sandbox-runner/session.v4.jsonl @@ -21,7 +21,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791427848744,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791427848744,"index":0,"dt":[],"id":"missing-runner-output","name":"job_output","args":["{\"job_id\":\"bash-1\",\"wait\":true}"]},{"type":"chunk","time":1791427848744,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"}}},{"type":"chunk","time":1791427848744,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791427848744,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"missing-runner-output"},"toolCallId":"missing-runner-output","content":[{"type":"text","text":"Error: unknown job bash-1"}],"isError":true,"id":"{{message:7}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"missing-runner-output"},"toolCallId":"missing-runner-output","content":[{"type":"text","text":"Error: unknown job bash-1"}],"isError":true,"id":"{{message:7}}"},"error":{"name":"JobError","code":"JOB_NOT_FOUND"}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"RUNNER_FAILURES_SURFACED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791427848761,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1791427848761,"index":0,"dt":[],"texts":["RUNNER_FAILURES_SURFACED"]},{"type":"chunk","time":1791427848762,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}},{"type":"chunk","time":1791427848762,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791427848762,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From c92eded7e4eca82ce31f4b41c0a2d35bce9599da Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 18:59:04 +0800 Subject: [PATCH 22/81] docs: regenerate catalogs for the shell, sandbox, and working-directory errors Regenerated Cordis, config, and persistence catalogs pick up the new WorkingDirectoryError and SandboxPreparationError exports and the shifted source lines; persisted type digests are unchanged. Refs #7869 --- docs/config-catalog.md | 14 +++++++------- docs/config-catalog.zh.md | 14 +++++++------- docs/persistence-catalog.i18n.yaml | 16 ++++++++-------- docs/persistence-catalog.md | 8 ++++---- docs/persistence-catalog.zh.md | 8 ++++---- docs/persistence-schema.json | 6 +++--- docs/subsystems/sandbox.md | 2 ++ docs/subsystems/sandbox.zh.md | 2 ++ docs/subsystems/working-directory.md | 7 +++++-- docs/subsystems/working-directory.zh.md | 7 +++++-- .../extensions/tool-cordis/src/api-catalog.ts | 5 +++-- 11 files changed, 50 insertions(+), 39 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7562c8bf96..90892cc3dd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1891,7 +1891,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:47`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -2938,7 +2938,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:63`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -3362,7 +3362,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:57`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ @@ -4083,7 +4083,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:39`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4113,7 +4113,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:448`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -4348,7 +4348,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:56`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ @@ -4942,7 +4942,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:67`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:70`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0d75b6e523..663fb50b89 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1893,7 +1893,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:47`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -2940,7 +2940,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:63`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -3364,7 +3364,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:57`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ @@ -4085,7 +4085,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:39`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4115,7 +4115,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:448`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -4350,7 +4350,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:56`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ @@ -4944,7 +4944,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:67`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:70`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index a5fb682b48..4214f43702 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -300,8 +300,8 @@ en: e8dd0cebbb930eb7 zh: e8dd0cebbb930eb7 /session-persistence-event-catalog/events/working-directory/working-directory-change-log-only: - en: fc893ed0d93765df - zh: 2df365d9d0ac6f6e + en: 27f0761cbdd6fe2f + zh: 33528f333498132d /session-persistence-event-catalog/events/workspace: en: 63ee1a85e4b59ae3 zh: 63ee1a85e4b59ae3 @@ -1203,8 +1203,8 @@ en: 1f691c223f54ab2c zh: f88fef3ee9745c91 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: - en: c9fa5343ea99733b - zh: d6de41351c57a73f + en: 5adc2bf6745ccc67 + zh: 566cb9aaa4a177c9 /session-persistence-event-catalog/resolved-persistence-types/schedulechange: en: b20a355fde48f699 zh: 5d0fd858e2dbff06 @@ -1494,8 +1494,8 @@ en: bef51e0442ebb903 zh: 240ebeb160533d8c /session-persistence-event-catalog/resolved-persistence-types/cwd: - en: 35feea0430c0f14b - zh: d5aaf1d2ca80114a + en: 2809c921c6996791 + zh: 80b15177561766d7 /session-persistence-event-catalog/resolved-persistence-types/decision-durationms-exitcode-handlerid: en: a99ac57bbec0370f zh: 9073329e324a66e0 @@ -1929,8 +1929,8 @@ en: 0b74295f7c5c8f11 zh: 5f1213cec87f6be0 /session-persistence-event-catalog/resolved-persistence-types/kind-working-directory: - en: 626ea3180b86e5b6 - zh: 4b6e0dc1312b3c5a + en: b28ca6b37f5a338d + zh: 4c4f2f05721b8b62 /session-persistence-event-catalog/resolved-persistence-types/max-tokens-messages-model-thinking: en: 3216b23c131d0070 zh: 73ca171c6014f16e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 6b80fbbc99..cd09a9358c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1329,7 +1329,7 @@ Source: [`packages/web/web-search-deepseek/src/provider.ts:89`](../packages/web/ } ``` -Source: [`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +Source: [`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) ### `workspace/*` @@ -4619,7 +4619,7 @@ One of: SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` -Sources: [`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts) +Sources: [`packages/sandbox/sandbox/src/index.ts:38`](../packages/sandbox/sandbox/src/index.ts) One of: @@ -6209,7 +6209,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:24`](../packages/compacti SHA-256: `da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582` -Sources: [`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +Sources: [`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) | Property | Presence | Type | |---|---|---| @@ -7949,7 +7949,7 @@ Sources: [`packages/experimental/webhook/src/types.ts:74`](../packages/experimen SHA-256: `2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5` -Sources: [`packages/session/working-directory/src/index.ts:45`](../packages/session/working-directory/src/index.ts) +Sources: [`packages/session/working-directory/src/index.ts:48`](../packages/session/working-directory/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c73b594c28..84c826fe67 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1331,7 +1331,7 @@ export type SessionEvent = { } ``` -来源:[`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +来源:[`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) ### `workspace/*` @@ -4621,7 +4621,7 @@ SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` -来源:[`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts) +来源:[`packages/sandbox/sandbox/src/index.ts:38`](../packages/sandbox/sandbox/src/index.ts) 以下类型之一: @@ -6211,7 +6211,7 @@ SHA-256: `3e4e7683b0192c9f0e6a75c59076108f017892cdb302a98032c2632fd79c356f` SHA-256: `da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582` -来源:[`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +来源:[`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7951,7 +7951,7 @@ SHA-256: `9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94` SHA-256: `2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5` -来源:[`packages/session/working-directory/src/index.ts:45`](../packages/session/working-directory/src/index.ts) +来源:[`packages/session/working-directory/src/index.ts:48`](../packages/session/working-directory/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 3a1b058f36..a2832eb98c 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -159,7 +159,7 @@ {"digest":"2142e391b9ccb60d487afcf373ce74db98d952f69e8289eddb352a7e16e996c5","names":[],"sources":[]}, {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/team/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/team/agent-team/src/types.ts:121"]}, {"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d","names":[],"sources":[]}, - {"digest":"2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5","names":[],"sources":["packages/session/working-directory/src/index.ts:45"]}, + {"digest":"2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5","names":[],"sources":["packages/session/working-directory/src/index.ts:48"]}, {"digest":"224aa11d4d5f88c450822d01b99e6ca5e3a9c7f26a5ede10bae33384a09ef4dc","names":[],"sources":[]}, {"digest":"234142ee4f996f38cc9fa3b8b3e3de29b03c9aefe5ebf6c224eddd564505aa85","names":[],"sources":[]}, {"digest":"237c004c73b6b288246816e0d7fde16e09c63a47aa3b1227383d7afe58666163","names":[],"sources":[]}, @@ -607,7 +607,7 @@ {"digest":"d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241","names":[],"sources":[]}, {"digest":"d9c462b9f62fdd3efc76a44b9524dc8781f6b91cd9c7e584b17d971defafebab","names":["packages/llm/llm/src/message.ts#ToolMessageSource"],"sources":["packages/llm/llm/src/message.ts:29"]}, {"digest":"d9edf1862e4471d6ad8a106a3f4160e0643874368707d50ecd8e27e5ee7a2567","names":[],"sources":[]}, - {"digest":"da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582","names":[],"sources":["packages/session/working-directory/src/index.ts:52"]}, + {"digest":"da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582","names":[],"sources":["packages/session/working-directory/src/index.ts:55"]}, {"digest":"dacf3d5f3e4ff619c1320e64a48dec5e7ecd7870bece65fbd449a0e63adfa521","names":[],"sources":[]}, {"digest":"db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c","names":[],"sources":[]}, {"digest":"db6c5246911d47a7969f6b4cb47c7d0dbbde75c402710b22ace3672fa97bebe0","names":[],"sources":[]}, @@ -692,7 +692,7 @@ {"digest":"f97f47b0eb87905ed77cc27c5e4ed75ef805cb372c98905253709de1aab9b63c","names":[],"sources":[]}, {"digest":"f9cfc89e8fe085b4fcc9cd32b74b6f103608c54d7d8cd97b47c01de34f252ba3","names":[],"sources":[]}, {"digest":"fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be","names":["packages/llm/llm/src/message.ts#AssistantMessage"],"sources":["packages/llm/llm/src/message.ts:167"]}, - {"digest":"fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd","names":["packages/sandbox/sandbox/src/index.ts#SandboxMode"],"sources":["packages/sandbox/sandbox/src/index.ts:30"]}, + {"digest":"fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd","names":["packages/sandbox/sandbox/src/index.ts#SandboxMode"],"sources":["packages/sandbox/sandbox/src/index.ts:38"]}, {"digest":"fab35c4520883c0d69e42d079c9f543fc5ac7a2d15b537b5572fcd99059c7018","names":[],"sources":[]}, {"digest":"fb5e0517050d2c6d40e5b8145db1f7c5ca7044d5d50fada6ff80e0b22c6ca973","names":[],"sources":[]}, {"digest":"fb8b6a2e53cb9cf44c551176de0fb77d1ddc60037b99a891ff8a860b6d5c29a0","names":[],"sources":[]}, diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index e5c3021503..71f4eedea6 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -213,6 +213,8 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. + * @throws `SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session's recorded cwd is + * relative or contains a NUL byte. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 10093b09e2..2b79f4c17c 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -213,6 +213,8 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. + * @throws `SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session's recorded cwd is + * relative or contains a NUL byte. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy diff --git a/docs/subsystems/working-directory.md b/docs/subsystems/working-directory.md index c6f4545328..cac8b9afa4 100644 --- a/docs/subsystems/working-directory.md +++ b/docs/subsystems/working-directory.md @@ -42,7 +42,8 @@ get(session: Session): string * @param signal - cancellation for filesystem inspection. * @returns the existing directory; recovery is committed before fulfillment. * A notice failure is warned without reverting the committed state. - * @throws when the original project is also unavailable, or when a `working-directory/access` listener rejects. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also + * unavailable or the service is disposed, or the rejection of a `working-directory/access` listener. */ ensure(agent: Agent, signal?: AbortSignal): Promise @@ -53,7 +54,9 @@ ensure(agent: Agent, signal?: AbortSignal): Promise * @param signal - cancellation before the durable change. * @returns the canonical absolute directory, committed before fulfillment. * A notice failure is warned; the next request still receives the committed directory. - * @throws when the requested path is not an existing directory. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and + * `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a + * `working-directory/access` listener. */ set(agent: Agent, path: string, signal?: AbortSignal): Promise ``` diff --git a/docs/subsystems/working-directory.zh.md b/docs/subsystems/working-directory.zh.md index 98020a3dfc..aa6270328a 100644 --- a/docs/subsystems/working-directory.zh.md +++ b/docs/subsystems/working-directory.zh.md @@ -42,7 +42,8 @@ get(session: Session): string * @param signal - cancellation for filesystem inspection. * @returns the existing directory; recovery is committed before fulfillment. * A notice failure is warned without reverting the committed state. - * @throws when the original project is also unavailable, or when a `working-directory/access` listener rejects. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also + * unavailable or the service is disposed, or the rejection of a `working-directory/access` listener. */ ensure(agent: Agent, signal?: AbortSignal): Promise @@ -53,7 +54,9 @@ ensure(agent: Agent, signal?: AbortSignal): Promise * @param signal - cancellation before the durable change. * @returns the canonical absolute directory, committed before fulfillment. * A notice failure is warned; the next request still receives the committed directory. - * @throws when the requested path is not an existing directory. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and + * `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a + * `working-directory/access` listener. */ set(agent: Agent, path: string, signal?: AbortSignal): Promise ``` diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 74a5703129..7f3a279bf7 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2620,6 +2620,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Resolve the complete policy for one capability call. An approved explicit mode outranks the session\'s last `sandbox/mode` event, which outranks the deployment default. A session cwd is its workspace-write boundary; the configured root is the fallback for agentless calls and sessions without a cwd. Configured extra roots and this Session\'s active registrations are snapshotted together; existing operations and processes keep their policy.', parameters: [{ name: 'request', description: 'optional session and approved mode override.' }], returns: 'the fully resolved per-call mode and absolute workspace root.', + throws: ['`SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session\'s recorded cwd is relative or contains a NUL byte.'], }, { signature: 'registerWritableRoot(session: Session, directory: string): () => void', @@ -4550,14 +4551,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Validate the current directory and restore the original project when it disappeared.', parameters: [{ name: 'agent', description: 'live or unpublished Agent owning the Session.' }, { name: 'signal', description: 'cancellation for filesystem inspection.' }], returns: 'the existing directory; recovery is committed before fulfillment. A notice failure is warned without reverting the committed state.', - throws: ['when the original project is also unavailable, or when a `working-directory/access` listener rejects.'], + throws: ['{@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also unavailable or the service is disposed, or the rejection of a `working-directory/access` listener.'], }, { signature: 'set(agent: Agent, path: string, signal?: AbortSignal): Promise', description: 'Change one Session\'s directory without changing existing processes or permissions.', parameters: [{ name: 'agent', description: 'live or unpublished Agent owning the Session.' }, { name: 'path', description: 'absolute path or a path relative to its current directory.' }, { name: 'signal', description: 'cancellation before the durable change.' }], returns: 'the canonical absolute directory, committed before fulfillment. A notice failure is warned; the next request still receives the committed directory.', - throws: ['when the requested path is not an existing directory.'], + throws: ['{@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a `working-directory/access` listener.'], }, ], }, From fe2effea1f77b37e89f1d7a99286fdedbab75e38 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 20:51:38 +0800 Subject: [PATCH 23/81] feat(terminal): give TerminalBackendCleanupError the CLEANUP_FAILED code TerminalBackendCleanupError extended AggregateError, so a backend that failed startup and then failed to clean its partial resources reached tool/result with a name but no error.name/error.code. It now extends TerminalError with code CLEANUP_FAILED, keeps spawnError, cleanupError, and the errors list, and sets the setup failure as cause. TerminalError and the cleanup error move to src/error.ts so src/types.ts stays type-only; TerminalError's constructor accepts ErrorOptions. Refs #7869 --- docs/subsystems/terminal.md | 2 +- docs/subsystems/terminal.zh.md | 2 +- .../terminal-bash/tests/index.spec.ts | 3 + packages/terminal/terminal/README.i18n.yaml | 8 +-- packages/terminal/terminal/README.md | 5 +- packages/terminal/terminal/README.zh.md | 5 +- packages/terminal/terminal/src/error.ts | 70 +++++++++++++++++++ packages/terminal/terminal/src/index.ts | 40 +---------- packages/terminal/terminal/src/types.ts | 17 +---- .../terminal/terminal/tests/service.spec.ts | 16 +++++ 10 files changed, 104 insertions(+), 64 deletions(-) create mode 100644 packages/terminal/terminal/src/error.ts diff --git a/docs/subsystems/terminal.md b/docs/subsystems/terminal.md index c1c78963e4..0d917219d5 100644 --- a/docs/subsystems/terminal.md +++ b/docs/subsystems/terminal.md @@ -31,7 +31,7 @@ A backend owns how one registered type starts and detects readiness. `TerminalSe interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } ``` diff --git a/docs/subsystems/terminal.zh.md b/docs/subsystems/terminal.zh.md index e97e5771f5..a9d2b36d27 100644 --- a/docs/subsystems/terminal.zh.md +++ b/docs/subsystems/terminal.zh.md @@ -31,7 +31,7 @@ type TerminalSessionStatus = interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } ``` diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index d992f8ee7f..2859644622 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -158,8 +158,11 @@ describe('BashTerminalBackend startup rollback', () => { const aggregate = new BashTerminalBackend(ctx, config(), spawnTerminal, () => doublyFailed) await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalBackendCleanupError', + code: 'CLEANUP_FAILED', spawnError: startupFailure, cleanupError: cleanupFailure, + errors: [startupFailure, cleanupFailure], + cause: startupFailure, } satisfies Partial)) }) diff --git a/packages/terminal/terminal/README.i18n.yaml b/packages/terminal/terminal/README.i18n.yaml index 6b91667f72..e734b14bf2 100644 --- a/packages/terminal/terminal/README.i18n.yaml +++ b/packages/terminal/terminal/README.i18n.yaml @@ -39,14 +39,14 @@ en: 6bae42fb7275816f zh: 5f75e1b26074108c /deepseek-ai-dsh-terminal/understand-the-implementation/source-map: - en: 48dab6fe2f770ced - zh: 9bbfd23bcc5ac2b3 + en: 3ab5d082aa13d500 + zh: e97b962d02e474b7 /deepseek-ai-dsh-terminal/understand-the-implementation/data-model-and-lifecycle: en: a2adfa2708882e19 zh: 002f257fd8c4addb /deepseek-ai-dsh-terminal/understand-the-implementation/ownership-and-cleanup-rules: - en: 7af36e58919e41d5 - zh: 4fe7b4db1099bcbf + en: b669a68c739b062a + zh: cc641f850905095f /deepseek-ai-dsh-terminal/understand-the-implementation/send-reservation: en: 0b8dc3a382aa7bf2 zh: e6cc6906e01827d2 diff --git a/packages/terminal/terminal/README.md b/packages/terminal/terminal/README.md index 40cb6866f8..ff34d9a3cc 100644 --- a/packages/terminal/terminal/README.md +++ b/packages/terminal/terminal/README.md @@ -74,7 +74,8 @@ The service owns everything except terminal mechanics: session identity, publica | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `TerminalSessionService`: backend registry, spawn/send/read/signal/kill/list, owner cleanups, disposal | -| [`src/types.ts`](src/types.ts) | Shared contracts: backend interface, session types, wait reasons, signal set, error codes | +| [`src/types.ts`](src/types.ts) | Shared contracts: backend interface, session types, wait reasons, signal set | +| [`src/error.ts`](src/error.ts) | `TerminalError`, its codes, and `TerminalBackendCleanupError` | ### Data model and lifecycle @@ -83,7 +84,7 @@ Each published session is a record of its id, owner, optional name, backend type ### Ownership and cleanup rules - Fencing uses the exact `Agent` object: `hasOwnerActivity(owner)` spans unpublished setup through final close with no publication gap, so lifecycle policy can fence the owner precisely. -- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. +- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`, a `TerminalError` with code `CLEANUP_FAILED` that lists both failures in `errors`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. - Caller cancellation keeps its exact `AbortSignal.reason`; `kill()` and disposal resolve only after the backend's captured process tree is quiescent. ### Send reservation diff --git a/packages/terminal/terminal/README.zh.md b/packages/terminal/terminal/README.zh.md index bb21c9e502..cef5a62110 100644 --- a/packages/terminal/terminal/README.zh.md +++ b/packages/terminal/terminal/README.zh.md @@ -74,7 +74,8 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `TerminalSessionService`:后端注册表、spawn/send/read/signal/kill/list、所有者清理与 dispose | -| [`src/types.ts`](src/types.ts) | 共享约定:后端接口、会话类型、等待原因、信号集合、错误码 | +| [`src/types.ts`](src/types.ts) | 共享约定:后端接口、会话类型、等待原因、信号集合 | +| [`src/error.ts`](src/error.ts) | `TerminalError`、其 code 与 `TerminalBackendCleanupError` | ### 数据模型与生命周期 @@ -83,7 +84,7 @@ kind: "package-reference" ### 所有权与清理规则 - 限制基于确切的 `Agent` 对象:`hasOwnerActivity(owner)` 覆盖从尚未发布的设置到最终关闭的全过程,没有发布竞态,因此生命周期策略可以精确限制所有者。 -- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 +- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝,它是 code 为 `CLEANUP_FAILED` 的 `TerminalError`,并在 `errors` 中列出两个失败;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 - 调用方取消保留其确切的 `AbortSignal.reason`;`kill()` 与 dispose 只在后端捕获的进程树完全停稳后完成。 ### 发送预留 diff --git a/packages/terminal/terminal/src/error.ts b/packages/terminal/terminal/src/error.ts new file mode 100644 index 0000000000..b487ac9bc4 --- /dev/null +++ b/packages/terminal/terminal/src/error.ts @@ -0,0 +1,70 @@ +/** Machine-routable PTY service and backend failures. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-error' + +/** + * Machine-routable PTY service and backend failures. Backends use + * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a + * confined mode has no sandbox provider, `SESSION_CLOSING` and + * `SESSION_EXITED` for an operation on a closing or exited session, + * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses + * readiness during startup, and `CLEANUP_FAILED` (always as + * {@link TerminalBackendCleanupError}) when startup failed and the partial + * resources it left could not be cleaned. + */ +export type TerminalErrorCode = + | 'CLEANUP_FAILED' + | 'DUPLICATE_BACKEND' + | 'DUPLICATE_NAME' + | 'FOREIGN_SESSION' + | 'INIT_REJECTED' + | 'NO_BACKEND' + | 'NO_SANDBOX' + | 'NO_SESSION' + | 'OWNER_NOT_LIVE' + | 'SEND_ACTIVE' + | 'SERVICE_DISPOSING' + | 'SESSION_CLOSING' + | 'SESSION_EXITED' + | 'STARTUP_EXITED' + | 'STARTUP_TIMEOUT' + +/** Harness error carrying a stable {@link TerminalErrorCode}. */ +export class TerminalError extends HarnessError { + override readonly code: TerminalErrorCode + + /** + * @param message - names the PTY operation and the safe next step. + * @param code - the failure class consumers route on. + * @param options - standard `cause` chaining. + */ + constructor(message: string, code: TerminalErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'TerminalError' + this.code = code + } +} + +/** + * Backend-reported failure to clean partial resources after unpublished setup + * failed; code `CLEANUP_FAILED`. Backend-owned resources may still be alive. + * `errors` lists both failures in the order `AggregateError` would, and + * `cause` is the setup failure. + */ +export class TerminalBackendCleanupError extends TerminalError { + /** The setup failure followed by the cleanup failure. */ + readonly errors: readonly unknown[] + + /** + * @param spawnError - original setup or cancellation failure. + * @param cleanupError - failure that may leave backend-owned resources alive. + */ + constructor( + readonly spawnError: unknown, + readonly cleanupError: unknown, + ) { + super('PTY backend startup and cleanup both failed', 'CLEANUP_FAILED', { cause: spawnError }) + this.name = 'TerminalBackendCleanupError' + this.errors = [spawnError, cleanupError] + } +} diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index df09ff94aa..2e6e78b6ea 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -6,8 +6,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { HarnessError } from '@deepseek-ai/dsh-error' -import { TerminalBackendCleanupError } from './types.ts' +import { TerminalBackendCleanupError, TerminalError } from './error.ts' import type { TerminalBackend, TerminalBackendSession, @@ -41,7 +40,7 @@ export type { TerminalSpawnResult, TerminalWaitReason, } from './types.ts' -export { TerminalBackendCleanupError } from './types.ts' +export { TerminalBackendCleanupError, TerminalError, type TerminalErrorCode } from './error.ts' /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */ export type TerminalSessionId = TerminalSessionIdValue @@ -52,41 +51,6 @@ declare module '@deepseek-ai/cordis' { } } -/** - * Machine-routable PTY service and backend failures. Backends use - * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a - * confined mode has no sandbox provider, `SESSION_CLOSING` and - * `SESSION_EXITED` for an operation on a closing or exited session, and - * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses - * readiness during startup. - */ -export type TerminalErrorCode = - | 'DUPLICATE_BACKEND' - | 'DUPLICATE_NAME' - | 'FOREIGN_SESSION' - | 'INIT_REJECTED' - | 'NO_BACKEND' - | 'NO_SANDBOX' - | 'NO_SESSION' - | 'OWNER_NOT_LIVE' - | 'SEND_ACTIVE' - | 'SERVICE_DISPOSING' - | 'SESSION_CLOSING' - | 'SESSION_EXITED' - | 'STARTUP_EXITED' - | 'STARTUP_TIMEOUT' - -/** Harness error carrying a stable {@link TerminalErrorCode}. */ -export class TerminalError extends HarnessError { - override readonly code: TerminalErrorCode - - constructor(message: string, code: TerminalErrorCode) { - super(message, code) - this.name = 'TerminalError' - this.code = code - } -} - /** * Brand one registry-minted string as a {@link TerminalSessionId}. * @param value - raw registry-issued id. diff --git a/packages/terminal/terminal/src/types.ts b/packages/terminal/terminal/src/types.ts index cbdd08d8a8..c3a191ba51 100644 --- a/packages/terminal/terminal/src/types.ts +++ b/packages/terminal/terminal/src/types.ts @@ -10,21 +10,6 @@ import type { Agent } from '@deepseek-ai/dsh-agent' /** Internal exported basis for the public `TerminalSessionId` type/value pair. */ export type TerminalSessionIdValue = Branded<'TerminalSessionId'> -/** - * Backend-reported failure to clean partial resources after unpublished setup failed. - * @param spawnError - original setup or cancellation failure. - * @param cleanupError - failure that may leave backend-owned resources alive. - */ -export class TerminalBackendCleanupError extends AggregateError { - constructor( - readonly spawnError: unknown, - readonly cleanupError: unknown, - ) { - super([spawnError, cleanupError], 'PTY backend startup and cleanup both failed') - this.name = 'TerminalBackendCleanupError' - } -} - /** Why one interactive send returned control to its caller. */ export type TerminalWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit' @@ -166,7 +151,7 @@ export interface TerminalBackendSession { export interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 0237f2970a..3199deb1a0 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -203,6 +203,9 @@ describe('TerminalSessionService ownership and lifecycle', () => { expect(concurrent).toBeInstanceOf(TerminalError) expect(concurrent).toBeInstanceOf(HarnessError) expect(concurrent).toMatchObject({ name: 'TerminalError', code: 'SEND_ACTIVE' }) + const cleanup = new TerminalBackendCleanupError(new Error('setup failed'), new Error('cleanup failed')) + expect(cleanup).toBeInstanceOf(TerminalError) + expect(cleanup).toMatchObject({ name: 'TerminalBackendCleanupError', code: 'CLEANUP_FAILED' }) expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false }) expect(operation.cancel()).toBe(true) await operation.done @@ -297,6 +300,19 @@ describe('TerminalSessionService ownership and lifecycle', () => { await expect(pending).rejects.toBe(reason) }) + it('rejects an uncancelled spawn with the backend cleanup failure and its code', async () => { + const ctx = await harness() + const failure = new TerminalBackendCleanupError(new Error('startup failed'), new Error('backend cleanup failed')) + ctx.terminals.registerBackend({ type: 'cleanup-failing', spawn: () => Promise.reject(failure) }) + const owner = stubAgent(ctx, 'owner') + await ctx.agents.register(owner) + + await expect(ctx.terminals.spawn(owner, { type: 'cleanup-failing' })).rejects.toBe(failure) + expect(failure).toMatchObject({ name: 'TerminalBackendCleanupError', code: 'CLEANUP_FAILED' }) + const internal = ctx.terminals as unknown as { disposeOwned(owner: Agent): Promise } + await expect(internal.disposeOwned(owner)).rejects.toThrow('failed to clean up PTY lifecycle') + }) + it.each(['owner', 'service'] as const)('retains caller-triggered backend cleanup failure until %s disposal', async (scope) => { const ctx = await harness() const started = Promise.withResolvers() From 690634f5e9ac7e28d643f69fcc94f0a81d20574e Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 22:05:35 +0800 Subject: [PATCH 24/81] refactor(errors): base the new shell, sandbox, and subprocess errors on dsh-error The error classes this branch adds import HarnessError from the dependency-free @deepseek-ai/dsh-error package: Win32Error, SubprocessError, and ShellEnvError packages depend on dsh-error instead of the LLM runtime, and dsh-sandbox and dsh-working-directory declare it beside their existing dsh-llm dependency. The SHELL_ENV and WORKING_DIRECTORY code lists stay in internal modules so the plugin entries export only the error classes and code types. Generated module, config, and event graphs follow. Refs #7869 --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.md | 32 ++++++++++++------- docs/module-graph.zh.md | 32 ++++++++++++------- packages/sandbox/sandbox/package.json | 6 ++-- packages/sandbox/sandbox/src/escalation.ts | 2 +- .../sandbox/sandbox/tests/vocabulary.spec.ts | 2 +- packages/sandbox/sandbox/tsconfig.json | 3 ++ .../session/working-directory/package.json | 6 ++-- .../session/working-directory/src/error.ts | 2 +- .../session/working-directory/src/index.ts | 2 +- .../session/working-directory/tsconfig.json | 3 ++ packages/shell/shell-env/README.i18n.yaml | 4 +-- packages/shell/shell-env/README.md | 1 + packages/shell/shell-env/README.zh.md | 1 + packages/shell/shell-env/package.json | 4 +-- packages/shell/shell-env/src/error.ts | 28 ++++++++++++++++ packages/shell/shell-env/src/index.ts | 29 ++--------------- packages/shell/shell-env/tsconfig.json | 2 +- packages/subprocess/subprocess/package.json | 4 +-- packages/subprocess/subprocess/src/error.ts | 2 +- .../subprocess/subprocess/tests/error.spec.ts | 2 +- packages/subprocess/subprocess/tsconfig.json | 2 +- .../subprocess/win32-process/package.json | 4 +-- .../subprocess/win32-process/src/errors.ts | 2 +- .../win32-process/tests/ffi.spec.ts | 2 +- .../subprocess/win32-process/tsconfig.json | 2 +- pnpm-lock.yaml | 22 ++++++++----- 30 files changed, 124 insertions(+), 85 deletions(-) create mode 100644 packages/shell/shell-env/src/error.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 90892cc3dd..f0e447ae59 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3362,7 +3362,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:57`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:34`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 663fb50b89..dad8488a45 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3364,7 +3364,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:57`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:34`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7e1a837be5..c0a83f9f7f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -98,7 +98,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:29`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | +| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:32`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | | `workspace/session-activity` | `waterfall` | [`packages/workspace/workspace/src/index.ts:129`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`waterfall`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | | `workspace/session-archived` | `parallel` | [`packages/workspace/workspace/src/index.ts:156`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | `worktree` | | `workspace/session-stop` | `parallel` | [`packages/workspace/workspace/src/index.ts:147`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 85f0623eaf..20e1101166 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -100,7 +100,7 @@ | `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:29`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | +| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:32`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | | `workspace/session-activity` | `waterfall` | [`packages/workspace/workspace/src/index.ts:129`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`waterfall`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | | `workspace/session-archived` | `parallel` | [`packages/workspace/workspace/src/index.ts:156`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | `worktree` | | `workspace/session-stop` | `parallel` | [`packages/workspace/workspace/src/index.ts:147`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 1f8dfaee91..dbd423430a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -505,7 +505,9 @@ flowchart TD pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage + pkg_subprocess --> pkg_error pkg_subprocess --> pkg_http_proxy + pkg_win32_process --> pkg_error pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_system_prompt --> pkg_llm @@ -539,6 +541,7 @@ flowchart TD pkg_host_frontend_static --> pkg_host_webserver pkg_lsp --> pkg_brand pkg_lsp --> pkg_llm + pkg_sandbox --> pkg_error pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session pkg_session_format_catalog --> pkg_session @@ -735,6 +738,7 @@ flowchart TD pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection pkg_working_directory --> pkg_agent + pkg_working_directory --> pkg_error pkg_working_directory --> pkg_fs pkg_working_directory --> pkg_llm pkg_working_directory --> pkg_session @@ -804,10 +808,6 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_typert_protocol pkg_permission_presets --> pkg_user_approval - pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_jobs - pkg_jobs_local --> pkg_scope - pkg_jobs_local --> pkg_timeout pkg_ptc_runtime_node --> pkg_fs pkg_ptc_runtime_node --> pkg_ptc_runtime pkg_ptc_runtime_node --> pkg_sandbox @@ -1023,6 +1023,11 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions + pkg_jobs_local --> pkg_agent + pkg_jobs_local --> pkg_jobs + pkg_jobs_local --> pkg_scope + pkg_jobs_local --> pkg_timeout + pkg_jobs_local --> pkg_tools pkg_tool_jobs --> pkg_agent pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm @@ -1059,6 +1064,7 @@ flowchart TD pkg_tool_working_directory --> pkg_tools pkg_tool_working_directory --> pkg_working_directory pkg_shell_env --> pkg_app_boot + pkg_shell_env --> pkg_error pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools @@ -1176,6 +1182,7 @@ flowchart TD pkg_tool_bash --> pkg_sandbox_policy pkg_tool_bash --> pkg_shell pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_subprocess pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval @@ -1187,6 +1194,7 @@ flowchart TD pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_shell pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_subprocess pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval @@ -1683,7 +1691,6 @@ flowchart TD | [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — | | [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — | | [`storage`](../packages/storage/storage) | `storage` | — | -| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | @@ -1702,7 +1709,8 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`error`](../packages/util/error), [`http-proxy`](../packages/util/http-proxy) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`error`](../packages/util/error) | | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1719,7 +1727,7 @@ flowchart TD | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`error`](../packages/util/error), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1780,7 +1788,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1795,7 +1803,6 @@ flowchart TD | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node) | `ptc-runtime` | [`fs`](../packages/fs/fs), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | @@ -1838,6 +1845,7 @@ flowchart TD | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1845,7 +1853,7 @@ flowchart TD | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-working-directory`](../packages/session/tool-working-directory) | `session` | [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`error`](../packages/util/error), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | @@ -1865,8 +1873,8 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) | | [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | `ssh` | [`sandbox`](../packages/sandbox/sandbox), [`ssh`](../packages/ssh/ssh) | | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index a6070b4f5e..caf5f595f8 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -507,7 +507,9 @@ flowchart TD pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage + pkg_subprocess --> pkg_error pkg_subprocess --> pkg_http_proxy + pkg_win32_process --> pkg_error pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_system_prompt --> pkg_llm @@ -541,6 +543,7 @@ flowchart TD pkg_host_frontend_static --> pkg_host_webserver pkg_lsp --> pkg_brand pkg_lsp --> pkg_llm + pkg_sandbox --> pkg_error pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session pkg_session_format_catalog --> pkg_session @@ -737,6 +740,7 @@ flowchart TD pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection pkg_working_directory --> pkg_agent + pkg_working_directory --> pkg_error pkg_working_directory --> pkg_fs pkg_working_directory --> pkg_llm pkg_working_directory --> pkg_session @@ -806,10 +810,6 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_typert_protocol pkg_permission_presets --> pkg_user_approval - pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_jobs - pkg_jobs_local --> pkg_scope - pkg_jobs_local --> pkg_timeout pkg_ptc_runtime_node --> pkg_fs pkg_ptc_runtime_node --> pkg_ptc_runtime pkg_ptc_runtime_node --> pkg_sandbox @@ -1025,6 +1025,11 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions + pkg_jobs_local --> pkg_agent + pkg_jobs_local --> pkg_jobs + pkg_jobs_local --> pkg_scope + pkg_jobs_local --> pkg_timeout + pkg_jobs_local --> pkg_tools pkg_tool_jobs --> pkg_agent pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm @@ -1061,6 +1066,7 @@ flowchart TD pkg_tool_working_directory --> pkg_tools pkg_tool_working_directory --> pkg_working_directory pkg_shell_env --> pkg_app_boot + pkg_shell_env --> pkg_error pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools @@ -1178,6 +1184,7 @@ flowchart TD pkg_tool_bash --> pkg_sandbox_policy pkg_tool_bash --> pkg_shell pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_subprocess pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval @@ -1189,6 +1196,7 @@ flowchart TD pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_shell pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_subprocess pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval @@ -1685,7 +1693,6 @@ flowchart TD | [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — | | [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — | | [`storage`](../packages/storage/storage) | `storage` | — | -| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | @@ -1704,7 +1711,8 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`error`](../packages/util/error), [`http-proxy`](../packages/util/http-proxy) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`error`](../packages/util/error) | | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1721,7 +1729,7 @@ flowchart TD | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`error`](../packages/util/error), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1782,7 +1790,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1797,7 +1805,6 @@ flowchart TD | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node) | `ptc-runtime` | [`fs`](../packages/fs/fs), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | @@ -1840,6 +1847,7 @@ flowchart TD | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1847,7 +1855,7 @@ flowchart TD | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-working-directory`](../packages/session/tool-working-directory) | `session` | [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`error`](../packages/util/error), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | @@ -1867,8 +1875,8 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) | | [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | `ssh` | [`sandbox`](../packages/sandbox/sandbox), [`ssh`](../packages/ssh/ssh) | | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 6b6f314b78..4445da55fa 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -32,12 +32,14 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-session": "workspace:*" + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-session": "workspace:*" + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-util-values": "workspace:*" diff --git a/packages/sandbox/sandbox/src/escalation.ts b/packages/sandbox/sandbox/src/escalation.ts index 84d5f49561..7e0193b6c8 100644 --- a/packages/sandbox/sandbox/src/escalation.ts +++ b/packages/sandbox/sandbox/src/escalation.ts @@ -16,7 +16,7 @@ * @module dsh-sandbox/escalation */ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { assertNever } from '@deepseek-ai/dsh-util-values' import { escalationDisplayReason } from './locales.ts' import type { SandboxMode } from './index.ts' diff --git a/packages/sandbox/sandbox/tests/vocabulary.spec.ts b/packages/sandbox/sandbox/tests/vocabulary.spec.ts index 26db8fe796..8c3002a4ea 100644 --- a/packages/sandbox/sandbox/tests/vocabulary.spec.ts +++ b/packages/sandbox/sandbox/tests/vocabulary.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { SANDBOX_PREPARATION_ERROR_CODES, SANDBOX_UNAVAILABLE, SandboxPreparationError, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' describe('SandboxUnavailableError', () => { diff --git a/packages/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json index 95245937ec..48fc673d7e 100644 --- a/packages/sandbox/sandbox/tsconfig.json +++ b/packages/sandbox/sandbox/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../util/error" } ] } diff --git a/packages/session/working-directory/package.json b/packages/session/working-directory/package.json index e7433cb7ca..9682f5da41 100644 --- a/packages/session/working-directory/package.json +++ b/packages/session/working-directory/package.json @@ -38,7 +38,8 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*" + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-sandbox-policy": "workspace:*", @@ -54,7 +55,8 @@ "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tool-working-directory": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~", diff --git a/packages/session/working-directory/src/error.ts b/packages/session/working-directory/src/error.ts index ec2a2ef7a2..acae1f0307 100644 --- a/packages/session/working-directory/src/error.ts +++ b/packages/session/working-directory/src/error.ts @@ -1,6 +1,6 @@ /** Machine-routable Session working-directory failures. @module */ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' /** * Every {@link WorkingDirectoryError} code: diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 7a0f66ede6..6ef5ce0279 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -15,7 +15,7 @@ import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' import { WorkingDirectoryError } from './error.ts' -export { WORKING_DIRECTORY_ERROR_CODES, WorkingDirectoryError, type WorkingDirectoryErrorCode } from './error.ts' +export { WorkingDirectoryError, type WorkingDirectoryErrorCode } from './error.ts' declare module '@deepseek-ai/cordis' { interface Events { diff --git a/packages/session/working-directory/tsconfig.json b/packages/session/working-directory/tsconfig.json index 6d24c1126a..7d67ef3a9f 100644 --- a/packages/session/working-directory/tsconfig.json +++ b/packages/session/working-directory/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/error" } ] } diff --git a/packages/shell/shell-env/README.i18n.yaml b/packages/shell/shell-env/README.i18n.yaml index eac66fcb65..871ab18c3a 100644 --- a/packages/shell/shell-env/README.i18n.yaml +++ b/packages/shell/shell-env/README.i18n.yaml @@ -36,8 +36,8 @@ en: 5438a951817526d9 zh: 66459cdea830a8c8 /deepseek-ai-dsh-shell-env/understand-the-implementation/source-map: - en: 72eba3bfadcfa581 - zh: dcd83e02a1910f27 + en: da18240c8c1dc687 + zh: a854ddaca8953832 /deepseek-ai-dsh-shell-env/understand-the-implementation/collection: en: 0c2d7d5b2131630f zh: b44d4f0ddf89421b diff --git a/packages/shell/shell-env/README.md b/packages/shell/shell-env/README.md index 86037caffe..f6b005367e 100644 --- a/packages/shell/shell-env/README.md +++ b/packages/shell/shell-env/README.md @@ -87,6 +87,7 @@ This section explains the design decisions behind the registry and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry, `ShellEnvRegistry` service, and the built-in facts | +| [`src/error.ts`](src/error.ts) | `ShellEnvError` and its codes | ### Collection diff --git a/packages/shell/shell-env/README.zh.md b/packages/shell/shell-env/README.zh.md index a3d4a98516..99abd27556 100644 --- a/packages/shell/shell-env/README.zh.md +++ b/packages/shell/shell-env/README.zh.md @@ -87,6 +87,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口、`ShellEnvRegistry` 服务与内置事实 | +| [`src/error.ts`](src/error.ts) | `ShellEnvError` 及其 code | ### 收集 diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 4fb7d7de4f..3393956475 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -37,7 +37,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-shell": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-shell": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } diff --git a/packages/shell/shell-env/src/error.ts b/packages/shell/shell-env/src/error.ts new file mode 100644 index 0000000000..cd320b4971 --- /dev/null +++ b/packages/shell/shell-env/src/error.ts @@ -0,0 +1,28 @@ +/** Machine-routable shell environment failures. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-error' + +/** + * Every {@link ShellEnvError} code: + * - `SHELL_ENV_CONTRIBUTION_INVALID` — a registered contributor returned an undeclared key or a non-string value; the + * shell command does not run, and the contributing plugin must be fixed. + */ +export const SHELL_ENV_ERROR_CODES = ['SHELL_ENV_CONTRIBUTION_INVALID'] as const + +/** One code from {@link SHELL_ENV_ERROR_CODES}. */ +export type ShellEnvErrorCode = typeof SHELL_ENV_ERROR_CODES[number] + +/** Shell environment failure carrying a stable {@link ShellEnvErrorCode}. */ +export class ShellEnvError extends HarnessError { + override readonly code: ShellEnvErrorCode + + /** + * @param message - names the contributor and the offending key. + * @param code - the failure class consumers route on. + */ + constructor(message: string, code: ShellEnvErrorCode) { + super(message, code) + this.name = 'ShellEnvError' + this.code = code + } +} diff --git a/packages/shell/shell-env/src/index.ts b/packages/shell/shell-env/src/index.ts index aa3012e2b4..ee84bf764b 100644 --- a/packages/shell/shell-env/src/index.ts +++ b/packages/shell/shell-env/src/index.ts @@ -14,42 +14,19 @@ import z from '@deepseek-ai/schemastery' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-shell' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { ShellEnvError } from './error.ts' import type { ToolExecution } from '@deepseek-ai/dsh-tools' // Declares `Context.profileContext`, the launcher-provided profile the built-ins read. import type {} from '@deepseek-ai/dsh-app-boot' +export { ShellEnvError, type ShellEnvErrorCode } from './error.ts' + declare module '@deepseek-ai/cordis' { interface Context { shellEnv: ShellEnvRegistry } } -/** - * Every {@link ShellEnvError} code: - * - `SHELL_ENV_CONTRIBUTION_INVALID` — a registered contributor returned an undeclared key or a non-string value; the - * shell command does not run, and the contributing plugin must be fixed. - */ -export const SHELL_ENV_ERROR_CODES = ['SHELL_ENV_CONTRIBUTION_INVALID'] as const - -/** One code from {@link SHELL_ENV_ERROR_CODES}. */ -export type ShellEnvErrorCode = typeof SHELL_ENV_ERROR_CODES[number] - -/** Shell environment failure carrying a stable {@link ShellEnvErrorCode}. */ -export class ShellEnvError extends HarnessError { - override readonly code: ShellEnvErrorCode - - /** - * @param message - names the contributor and the offending key. - * @param code - the failure class consumers route on. - */ - constructor(message: string, code: ShellEnvErrorCode) { - super(message, code) - this.name = 'ShellEnvError' - this.code = code - } -} - export const name = 'shell-env' export const inject: string[] = [] diff --git a/packages/shell/shell-env/tsconfig.json b/packages/shell/shell-env/tsconfig.json index ae66c37da9..050a016f80 100644 --- a/packages/shell/shell-env/tsconfig.json +++ b/packages/shell/shell-env/tsconfig.json @@ -30,7 +30,7 @@ "path": "../../boot/app-boot" }, { - "path": "../../llm/llm" + "path": "../../util/error" } ] } diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 4d4968fbfb..1ff80d99f7 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -37,12 +37,12 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-http-proxy": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-http-proxy": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" } } diff --git a/packages/subprocess/subprocess/src/error.ts b/packages/subprocess/subprocess/src/error.ts index 064dc494ae..febc495b63 100644 --- a/packages/subprocess/subprocess/src/error.ts +++ b/packages/subprocess/subprocess/src/error.ts @@ -1,6 +1,6 @@ /** Machine-routable subprocess failures shared by every `ctx.subprocess` provider and its consumers. @module */ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' /** * Every {@link SubprocessError} code: diff --git a/packages/subprocess/subprocess/tests/error.spec.ts b/packages/subprocess/subprocess/tests/error.spec.ts index ecdcb89a98..dbb0554043 100644 --- a/packages/subprocess/subprocess/tests/error.spec.ts +++ b/packages/subprocess/subprocess/tests/error.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { SUBPROCESS_ERROR_CODES, SubprocessError, diff --git a/packages/subprocess/subprocess/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json index a39b295358..c961cc9e54 100644 --- a/packages/subprocess/subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../util/launch-environment" }, { - "path": "../../llm/llm" + "path": "../../util/error" } ] } diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 7eae3438fc..205b2113f4 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -31,7 +31,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-lazy-require": "workspace:*", @@ -39,6 +39,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-error": "workspace:*" } } diff --git a/packages/subprocess/win32-process/src/errors.ts b/packages/subprocess/win32-process/src/errors.ts index d8cd4caa86..be3e531bc5 100644 --- a/packages/subprocess/win32-process/src/errors.ts +++ b/packages/subprocess/win32-process/src/errors.ts @@ -1,4 +1,4 @@ -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' /** Code of every {@link Win32Error}; the failing API and its numeric code stay in `api`, `win32Code`, and `message`. */ export const WIN32_ERROR = 'WIN32_ERROR' diff --git a/packages/subprocess/win32-process/tests/ffi.spec.ts b/packages/subprocess/win32-process/tests/ffi.spec.ts index a202503369..c3f4a9bfb8 100644 --- a/packages/subprocess/win32-process/tests/ffi.spec.ts +++ b/packages/subprocess/win32-process/tests/ffi.spec.ts @@ -1,6 +1,6 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-error' import { WIN32_ERROR, Win32Error, diff --git a/packages/subprocess/win32-process/tsconfig.json b/packages/subprocess/win32-process/tsconfig.json index ac86e810e8..7430b866fa 100644 --- a/packages/subprocess/win32-process/tsconfig.json +++ b/packages/subprocess/win32-process/tsconfig.json @@ -13,7 +13,7 @@ "path": "../../util/lazy-require" }, { - "path": "../../llm/llm" + "path": "../../util/error" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b81d0ed5a..341f928b4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14162,6 +14162,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm @@ -15314,6 +15317,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:* version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-fs': specifier: workspace:* version: link:../../fs/fs @@ -15519,12 +15525,12 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../../boot/app-boot + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-home-paths': specifier: workspace:* version: link:../../util/home-paths - '@deepseek-ai/dsh-llm': - specifier: workspace:* - version: link:../../llm/llm '@deepseek-ai/dsh-shell': specifier: workspace:* version: link:../shell @@ -16987,15 +16993,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-error': + specifier: workspace:* + version: link:../../util/error '@deepseek-ai/dsh-http-proxy': specifier: workspace:* version: link:../../util/http-proxy '@deepseek-ai/dsh-launch-environment': specifier: workspace:* version: link:../../util/launch-environment - '@deepseek-ai/dsh-llm': - specifier: workspace:* - version: link:../../llm/llm packages/subprocess/subprocess-local: dependencies: @@ -17037,9 +17043,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-llm': + '@deepseek-ai/dsh-error': specifier: workspace:* - version: link:../../llm/llm + version: link:../../util/error packages/team/agent-team: dependencies: From 11a5bf8a55bb11a9ce7ae7e54775776c0dfca395 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:06:13 +0800 Subject: [PATCH 25/81] refactor(tool-fs): keep attachment store codes in read_image failures The attachment store already classifies its rejections, so read_image no longer translates them into ImageInputError codes. An AttachmentError keeps its name and code; for codes with a known remedy the tool rethrows it with a message naming the file and the next step, and the store error as cause. Other store failures pass through unchanged. ImageInputError keeps only the refusals the tool makes before storing the image: IMAGE_UNSUPPORTED_MODEL and IMAGE_UNSUPPORTED_FORMAT. The removed codes (IMAGE_TYPE_MISMATCH, IMAGE_INVALID, IMAGE_TOO_LARGE, IMAGE_CONVERSION_FAILED, IMAGE_STORE_FAILED) were never released. --- .../attachment-error-codes/guide.i18n.yaml | 4 +- .../attachment-error-codes/guide.md | 2 +- .../attachment-error-codes/guide.zh.md | 2 +- packages/fs/tool-fs/README.i18n.yaml | 8 ++-- packages/fs/tool-fs/README.md | 4 +- packages/fs/tool-fs/README.zh.md | 4 +- packages/fs/tool-fs/src/image-error.ts | 22 ++------- packages/fs/tool-fs/src/read-image.ts | 46 ++++++++++-------- packages/fs/tool-fs/tests/read-image.spec.ts | 48 ++++++++++--------- 9 files changed, 67 insertions(+), 73 deletions(-) diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml index 091cae83fa..66e712178e 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 4f2b2f0db0646d3b zh: 9467f41336a65c48 /attachment-failures-keep-their-codes/change: - en: daa5a93cf418a352 - zh: 3b391fbb4a87f2b1 + en: 0f8c05c401f15a7d + zh: adf5976a05802bf1 /attachment-failures-keep-their-codes/migration: en: 8599ead7a17bc7eb zh: 1120fd02065567f5 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md index 263ddf5df8..70acc07793 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md @@ -12,7 +12,7 @@ English | [中文](guide.zh.md) `AttachmentError` from `@deepseek-ai/dsh-attachment` now extends `HarnessError` from `@deepseek-ai/dsh-error`. Its `name` (`AttachmentError`) and every `ATTACHMENT_*` and image admission code are unchanged. Two persisted results change: - When a model request fails because an adapter could not read an image attachment, the failed turn's `turn/end` error previously recorded `code: 'UNKNOWN'`. It now records the `AttachmentError` code, such as `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, `ATTACHMENT_READ_FAILED`, or an image admission code such as `INVALID_IMAGE` or `IMAGE_TOO_LARGE`. -- A tool whose body lets an `AttachmentError` escape now records `error: { name: 'AttachmentError', code }` on its `tool/result`; previously the result had no `error`. `read_image` converts attachment failures to `ImageInputError` codes instead. +- A tool whose body lets an `AttachmentError` escape now records `error: { name: 'AttachmentError', code }` on its `tool/result`; previously the result had no `error`. `read_image` also records the `AttachmentError` code and names the file and the next step in the message. `instanceof HarnessError` now matches `AttachmentError`. `isAttachmentError` is unchanged. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md index 674f00805b..7c00e63528 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md @@ -12,7 +12,7 @@ description: "AttachmentError 现在继承 HarnessError,工具失败与模型 `@deepseek-ai/dsh-attachment` 的 `AttachmentError` 现在继承 `@deepseek-ai/dsh-error` 中的 `HarnessError`。其 `name`(`AttachmentError`)以及所有 `ATTACHMENT_*` 与图像准入错误码均保持不变。两类持久化结果会变化: - 适配器无法读取图像附件而导致模型请求失败时,失败 turn 的 `turn/end` 错误过去记录 `code: 'UNKNOWN'`。现在记录 `AttachmentError` 的错误码,例如 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT`、`ATTACHMENT_READ_FAILED`,或 `INVALID_IMAGE`、`IMAGE_TOO_LARGE` 等图像准入错误码。 -- 工具主体让 `AttachmentError` 逸出时,其 `tool/result` 现在记录 `error: { name: 'AttachmentError', code }`;过去结果中没有 `error`。`read_image` 则把附件失败转换为 `ImageInputError` 错误码。 +- 工具主体让 `AttachmentError` 逸出时,其 `tool/result` 现在记录 `error: { name: 'AttachmentError', code }`;过去结果中没有 `error`。`read_image` 同样记录 `AttachmentError` 的错误码,并在消息中写明文件与下一步。 `instanceof HarnessError` 现在会匹配 `AttachmentError`。`isAttachmentError` 不变。 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index e8c96ec826..f9d9c8ac82 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -30,8 +30,8 @@ en: 9bddebaf4aff0a8d zh: fea0196162e3fcd1 /deepseek-ai-dsh-tool-fs/use-this-package/failures-and-recovery: - en: 06fe9bd1d49c1d53 - zh: a8a6963daae11267 + en: 4e33f0736e64c72b + zh: b7a0f2386760e08e /deepseek-ai-dsh-tool-fs/understand-the-implementation: en: e30f7038c28e93f8 zh: a3868dbd90367b13 @@ -39,8 +39,8 @@ en: 1f4d7213aa3d0d60 zh: aa6573f96c8b5220 /deepseek-ai-dsh-tool-fs/understand-the-implementation/source-map: - en: 791043de5ce46710 - zh: c5315d0195908f83 + en: 8be089bfe40bb7ab + zh: 7753de2feca62205 /deepseek-ai-dsh-tool-fs/understand-the-implementation/per-tool-flow: en: 35fbee1ef9811806 zh: 17c725e2b742c073 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index cf3ef031df..93985eeb21 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -83,7 +83,7 @@ With the policy plugin mounted, `write` and `edit` obtain their guard from the ` For `apply_patch`, syntax and preflight refusals throw with `isError: true` before any disk change. `FsError` preserves filesystem codes; `PatchToolError extends HarnessError` carries `PATCH_SYNTAX` and `PATCH_SYMLINK_SOURCE`. Once commit starts, the tool returns a value with `isError: false` even on failure or uncertainty, so known partial effects retain their diff metadata; a call cancelled during commit receives the tool registry's abort result like every other tool, and its completed changes remain visible through Review. `phase`, `cancelled`, ordered `operations` and `affectedPaths` describe the commit; each operation retains its index, kind, display paths, patch line, status, diagnostic and known changes. Full `before`/`after` text serves rendering and PTC; the Session stores the rendered result plus `meta.diffs` and `meta.stopped`. Creations store no diff, as for `write`; deletions and move sources store a removal diff only when the provider returned a bounded basis, using `newText: ''` because `FileDiff.newText` cannot be null. -`read_image` refusals throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model), `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file), `IMAGE_TYPE_MISMATCH` (rename or convert the file), `IMAGE_INVALID` (truncated or corrupt bytes), `IMAGE_TOO_LARGE` (downscale the image), `IMAGE_CONVERSION_FAILED` (a 16-bit PNG could not be normalized; convert it to 8-bit), and `IMAGE_STORE_FAILED` (any other attachment-store failure, kept as `cause`). An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; any store failure other than an `AttachmentError`, such as an `AbortError`, another `HarnessError`, or a defect, passes through unchanged. +`read_image` refusals it makes before storing the image throw `ImageInputError extends HarnessError` with an `ImageInputErrorCode`: `IMAGE_UNSUPPORTED_MODEL` (switch to an image-capable model) or `IMAGE_UNSUPPORTED_FORMAT` (the extension, file signature, or deployment media types exclude the format; convert the file). Refusals from the attachment store keep their `AttachmentError` `name` and `code`, such as `INVALID_IMAGE`, `IMAGE_TYPE_MISMATCH`, or `IMAGE_DIMENSION_TOO_LARGE`; for codes with a known remedy, the message names the file and the next step, and the store error is the `cause`. An empty `file_path` throws `ToolArgsError` (`INVALID_ARGS`); a missing attachment service or an unresolvable model route throws `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`); filesystem failures keep their `FsError` codes; any other store failure, such as an `AbortError`, another `HarnessError`, or a defect, passes through unchanged. Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include the `ToolArgsError` (`INVALID_ARGS`) messages `invalid arguments: file_path must be a non-empty string` and `invalid arguments: limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. `FS_NOT_OBSERVED` is normalized to `cannot modify "": file has not been read — read the file, then retry`, independent of whether the policy or provider rejected the operation; `FS_STALE_VERSION` retains the provider's reason and appends `— re-read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. @@ -108,7 +108,7 @@ The tools are the executor; policy is an event gate. The tools inject no policy | [`src/index.ts`](src/index.ts) | Plugin entry: `Config`, tool composition, `read_image` attachments gate | | [`src/read.ts`](src/read.ts) | `read` executor: one stat, streaming decision, window build, observation | | [`src/read-image.ts`](src/read-image.ts) | `read_image` executor: route and media-type gates, bounded bytes, attachment save | -| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` and its closed `read_image` failure codes | +| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` and the closed codes for refusals `read_image` makes itself | | [`src/write.ts`](src/write.ts) | `write` executor: intent waterfall, atomic write, observation | | [`src/apply-patch.ts`](src/apply-patch.ts), [`apply-patch-output.ts`](src/apply-patch-output.ts) | `apply_patch` registration, strict commit values and compact diagnostics | | [`src/apply-patch-presentation.ts`](src/apply-patch-presentation.ts) | Pure call previews and known result diffs | diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 09af000f07..ec28bf145c 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" `apply_patch` 的语法与预检拒绝在磁盘变更前抛错,结果为 `isError: true`。`FsError` 保留文件系统错误码;`PatchToolError extends HarnessError` 携带 `PATCH_SYNTAX` 和 `PATCH_SYMLINK_SOURCE`。进入提交后,即使失败或结果不确定也返回 `isError: false` 的值,确保已知部分效果的 diff 元数据得以保留;提交期间被取消的调用与其他工具一样收到工具注册表的中止结果,已完成的变更仍可通过 Review 查看。`phase`、`cancelled`、有序 `operations` 与 `affectedPaths` 描述提交;每条操作保留索引、种类、展示路径、补丁行号、状态、诊断与已知变更。完整 `before`/`after` 文本供渲染和 PTC 使用;Session 保存渲染文本以及 `meta.diffs` 与 `meta.stopped`。新建与 `write` 一样不保存 diff;删除与移动源仅在提供方返回有界基线时保存删除 diff,并使用 `newText: ''`,因为 `FileDiff.newText` 不可为 null。 -`read_image` 的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)、`IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)、`IMAGE_TYPE_MISMATCH`(重命名或转换文件)、`IMAGE_INVALID`(字节被截断或损坏)、`IMAGE_TOO_LARGE`(缩小图像)、`IMAGE_CONVERSION_FAILED`(16 位 PNG 无法规范化;转换为 8 位)以及 `IMAGE_STORE_FAILED`(其他附件存储失败,原错误保留为 `cause`)。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的 `AttachmentError` 以外的失败(例如 `AbortError`、其他 `HarnessError` 或缺陷)原样传递。 +`read_image` 在存储图像前自行作出的拒绝抛出 `ImageInputError extends HarnessError`,错误码为 `ImageInputErrorCode`:`IMAGE_UNSUPPORTED_MODEL`(切换到支持图像的模型)或 `IMAGE_UNSUPPORTED_FORMAT`(扩展名、文件签名或部署接受的媒体类型排除了该格式;转换文件)。附件存储作出的拒绝保留其 `AttachmentError` 的 `name` 与 `code`,例如 `INVALID_IMAGE`、`IMAGE_TYPE_MISMATCH` 或 `IMAGE_DIMENSION_TOO_LARGE`;对有明确补救办法的错误码,消息会写明文件与下一步,存储错误作为 `cause`。空 `file_path` 抛出 `ToolArgsError`(`INVALID_ARGS`);缺少附件服务或无法解析模型路由时抛出 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`);文件系统失败保留其 `FsError` 错误码;存储抛出的其他失败(例如 `AbortError`、其他 `HarnessError` 或缺陷)原样传递。 失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `ToolArgsError`(`INVALID_ARGS`)消息 `invalid arguments: file_path must be a non-empty string` 与 `invalid arguments: limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。无论拒绝来自策略还是提供方,`FS_NOT_OBSERVED` 都规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 @@ -108,7 +108,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:`Config`、工具组合、`read_image` 附件门禁 | | [`src/read.ts`](src/read.ts) | `read` 执行器:一次 stat、流式决策、窗口构建、观察 | | [`src/read-image.ts`](src/read-image.ts) | `read_image` 执行器:路由与媒体类型门禁、有界字节、附件保存 | -| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` 及其封闭的 `read_image` 失败码 | +| [`src/image-error.ts`](src/image-error.ts) | `ImageInputError` 及 `read_image` 自行拒绝时使用的封闭错误码 | | [`src/write.ts`](src/write.ts) | `write` 执行器:意图 waterfall、原子写入、观察 | | [`src/apply-patch.ts`](src/apply-patch.ts)、[`apply-patch-output.ts`](src/apply-patch-output.ts) | `apply_patch` 注册、严格提交值与精简诊断 | | [`src/apply-patch-presentation.ts`](src/apply-patch-presentation.ts) | 纯调用预览与已知结果 diff | diff --git a/packages/fs/tool-fs/src/image-error.ts b/packages/fs/tool-fs/src/image-error.ts index a19a0b0f8d..ccacb48744 100644 --- a/packages/fs/tool-fs/src/image-error.ts +++ b/packages/fs/tool-fs/src/image-error.ts @@ -3,37 +3,23 @@ import { HarnessError } from '@deepseek-ai/dsh-error' /** - * Every machine-routable `read_image` failure code: + * Every `read_image` failure the tool classifies itself, before the attachment store sees the bytes: * * - `IMAGE_UNSUPPORTED_MODEL`: the calling route does not declare image input; switch to an image-capable model. * - `IMAGE_UNSUPPORTED_FORMAT`: the extension, the file signature, or the deployment's accepted media types * exclude the format; convert the file to an accepted format or read another file. - * - `IMAGE_TYPE_MISMATCH`: the extension or file signature declares one format but the bytes decode as another; rename or convert the file. - * - `IMAGE_INVALID`: bytes in a supported format do not decode; the file is truncated or corrupt. - * - `IMAGE_TOO_LARGE`: a side, the decoded pixel count, or the stored byte budget exceeds the deployment limit; - * downscale the image and read the smaller copy. - * - `IMAGE_CONVERSION_FAILED`: a 16-bit PNG could not be normalized to 8-bit sRGB; convert it to an 8-bit format. - * - `IMAGE_STORE_FAILED`: the attachment store could not commit the image for another reason; report the cause - * instead of retrying unchanged. + * + * Failures the attachment store classifies keep their `AttachmentError` codes. */ export const IMAGE_INPUT_ERROR_CODES = [ 'IMAGE_UNSUPPORTED_MODEL', 'IMAGE_UNSUPPORTED_FORMAT', - 'IMAGE_TYPE_MISMATCH', - 'IMAGE_INVALID', - 'IMAGE_TOO_LARGE', - 'IMAGE_CONVERSION_FAILED', - 'IMAGE_STORE_FAILED', ] as const /** One `read_image` failure code. */ export type ImageInputErrorCode = typeof IMAGE_INPUT_ERROR_CODES[number] -/** - * Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. `IMAGE_TOO_LARGE` and - * `IMAGE_TYPE_MISMATCH` share their spelling with `AttachmentError` codes, so consumers route on `name` - * and `code` together, and code-only predicates such as `isAttachmentError` do not identify this class. - */ +/** Typed `read_image` failure; `code` is one of {@link IMAGE_INPUT_ERROR_CODES}. */ export class ImageInputError extends HarnessError { override readonly code: ImageInputErrorCode diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 9f7d714ed6..be422b38aa 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -215,45 +215,49 @@ interface SaveFailureFacts { } /** - * Classify one `saveImage` rejection as the error `read_image` throws. - * Attachment admission codes become {@link ImageInputError} codes and other - * `AttachmentError` codes become `IMAGE_STORE_FAILED`, keeping the original as - * `cause`. The attachment store reports its own failures as `AttachmentError`, - * so any other value, including cancellation, passes through unchanged. + * Add the read path and the next step to one `saveImage` rejection. The attachment store + * classifies its own failures, so an `AttachmentError` keeps its `name` and `code` and only + * gains a message naming the file and remedy, with the original as `cause`; a code without + * a remedy, and any value that is not an `AttachmentError`, passes through unchanged. * @param error - the value `saveImage` rejected with. - * @param attachments - the store whose limits the refusal names. - * @param facts - the path and media-type facts the refusal names. + * @param attachments - the store whose limits the message names. + * @param facts - the path and media-type facts the message names. * @returns the error to throw from the tool body. */ function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFailureFacts): unknown { - const { displayPath, extension, mediaType, sniffed } = facts - const refusal = (detail: string, code: ImageInputError['code']) => - new ImageInputError(`cannot read "${displayPath}": ${detail}`, code, { cause: error }) - const storeFailed = (detail: string) => refusal(`the attachment store could not save the image: ${detail}`, 'IMAGE_STORE_FAILED') if (!(error instanceof AttachmentError)) return error + const detail = saveFailureDetail(error, attachments, facts) + return detail === undefined + ? error + : new AttachmentError(`cannot read "${facts.displayPath}": ${detail}`, error.code, { cause: error }) +} + +/** The model-facing remedy for one attachment rejection, or undefined when the store message stands. */ +function saveFailureDetail(error: AttachmentError, attachments: AttachmentStore, facts: SaveFailureFacts): string | undefined { + const { extension, mediaType, sniffed } = facts // Size refusals stay recoverable tool errors: an oversized image must never // enter durable history, where it would ride every later model request past // provider-side dimension rejections. switch (error.code) { case 'IMAGE_DIMENSION_TOO_LARGE': - return refusal(`at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`, 'IMAGE_TOO_LARGE') + return `at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy` case 'IMAGE_TOO_MANY_PIXELS': - return refusal(`the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`, 'IMAGE_TOO_LARGE') + return `the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy` case 'IMAGE_TOO_LARGE': case 'IMAGES_TOO_LARGE': - return refusal('the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy', 'IMAGE_TOO_LARGE') + return 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy' case 'UNSUPPORTED_IMAGE_TYPE': - return refusal(`${mediaType} images are not accepted by this deployment`, 'IMAGE_UNSUPPORTED_FORMAT') + return `${mediaType} images are not accepted by this deployment` case 'INVALID_IMAGE': - return refusal('the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt', 'IMAGE_INVALID') + return 'the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt' case 'IMAGE_TYPE_MISMATCH': return sniffed - ? refusal(`the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt`, 'IMAGE_TYPE_MISMATCH') - : refusal(`the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, 'IMAGE_TYPE_MISMATCH') + ? `the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt` + : `the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats` case 'ATTACHMENT_WRITE_FAILED': return /16-bit PNG/iu.test(error.message) - ? refusal('the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry', 'IMAGE_CONVERSION_FAILED') - : storeFailed(error.message) + ? 'the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry' + : undefined case 'TOO_MANY_IMAGES': case 'INVALID_IMAGE_BASE64': case 'INVALID_FILE_BASE64': @@ -263,7 +267,7 @@ function saveFailure(error: unknown, attachments: AttachmentStore, facts: SaveFa case 'ATTACHMENT_READ_FAILED': case 'ATTACHMENT_PROJECTION_UNSUPPORTED': case 'ATTACHMENT_FILES_UNSUPPORTED': - return storeFailed(error.message) + return undefined /* v8 ignore next -- Closed AttachmentErrorCode union; assertNever checks exhaustiveness at compile time. */ default: return assertNever(error.code) diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index bced7b466e..a55bb1c717 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -397,7 +397,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'broken' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('do not decode as a supported PNG/JPEG/WebP/GIF image') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_INVALID' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'INVALID_IMAGE' }) }) it('applies the deployment media-type policy to the sniffed format', async () => { @@ -463,7 +463,7 @@ describe('extension-less paths', () => { const result = await readImage(ctx, { file_path: 'sniffed' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('the file signature claims image/png, but the bytes decode as a different image format') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TYPE_MISMATCH' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'IMAGE_TYPE_MISMATCH' }) }) }) @@ -593,7 +593,7 @@ describe('image admission failures', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('the .jpg extension declares image/jpeg') expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TYPE_MISMATCH' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'IMAGE_TYPE_MISMATCH' }) }) it('caps an extension-less read at maxImageBytes before format detection', async () => { @@ -620,7 +620,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('exceeds the 4-pixel decoded-size limit') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TOO_LARGE' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'IMAGE_TOO_MANY_PIXELS' }) expect(text(result)).toContain('downscale the image and read the smaller copy') }) @@ -630,7 +630,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'wide.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('at least one image side exceeds the 2px limit') - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_TOO_LARGE' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'IMAGE_DIMENSION_TOO_LARGE' }) expect(text(result)).toContain('downscale the image and read the smaller copy') }) @@ -674,31 +674,35 @@ describe('image admission failures', () => { } it.each([ - ['IMAGE_TOO_LARGE', 'Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE', + ['IMAGE_TOO_LARGE', 'Image cannot be encoded within the configured normalized-image byte cap.', 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy'], - ['IMAGES_TOO_LARGE', 'Image batch exceeds the configured aggregate image-byte limit.', 'IMAGE_TOO_LARGE', + ['IMAGES_TOO_LARGE', 'Image batch exceeds the configured aggregate image-byte limit.', 'the image cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy'], - ['UNSUPPORTED_IMAGE_TYPE', 'Image type image/png is not accepted by this deployment.', 'IMAGE_UNSUPPORTED_FORMAT', + ['UNSUPPORTED_IMAGE_TYPE', 'Image type image/png is not accepted by this deployment.', 'image/png images are not accepted by this deployment'], - ['INVALID_IMAGE', 'Unsupported or malformed image data.', 'IMAGE_INVALID', + ['INVALID_IMAGE', 'Unsupported or malformed image data.', 'the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt'], - ['ATTACHMENT_WRITE_FAILED', 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', 'IMAGE_CONVERSION_FAILED', + ['ATTACHMENT_WRITE_FAILED', 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', 'the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry'], - ['ATTACHMENT_WRITE_FAILED', 'Unable to persist attachment.', 'IMAGE_STORE_FAILED', - 'the attachment store could not save the image: Unable to persist attachment.'], - ...([ - 'TOO_MANY_IMAGES', 'INVALID_IMAGE_BASE64', 'INVALID_FILE_BASE64', 'INVALID_ATTACHMENT_REF', 'ATTACHMENT_CORRUPT', - 'ATTACHMENT_NOT_FOUND', 'ATTACHMENT_READ_FAILED', 'ATTACHMENT_PROJECTION_UNSUPPORTED', 'ATTACHMENT_FILES_UNSUPPORTED', - ] as const).map(code => [code, 'Stored attachment failed.', 'IMAGE_STORE_FAILED', - 'the attachment store could not save the image: Stored attachment failed.'] as const), - ] as const)('classifies AttachmentError %s (%s) as %s', async (attachmentCode, message, code, detail) => { - const cause = new AttachmentError(message, attachmentCode) + ] as const)('keeps AttachmentError %s and adds the read path and remedy', async (code, message, detail) => { + const cause = new AttachmentError(message, code) const result = await failingSave(() => cause) expect(result.isError).toBe(true) - expect(result.error?.info).toEqual({ name: 'ImageInputError', code }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code }) expect(text(result)).toBe(`Error: cannot read "${join(dir, 'red.png')}": ${detail}`) }) + it.each([ + 'ATTACHMENT_WRITE_FAILED', 'TOO_MANY_IMAGES', 'INVALID_IMAGE_BASE64', 'INVALID_FILE_BASE64', 'INVALID_ATTACHMENT_REF', + 'ATTACHMENT_CORRUPT', 'ATTACHMENT_NOT_FOUND', 'ATTACHMENT_READ_FAILED', 'ATTACHMENT_PROJECTION_UNSUPPORTED', + 'ATTACHMENT_FILES_UNSUPPORTED', + ] as const)('passes AttachmentError %s without a read remedy through unchanged', async (code) => { + const result = await failingSave(() => new AttachmentError('Stored attachment failed.', code)) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code }) + expect(text(result)).toBe('Error: Stored attachment failed.') + }) + it('passes a store defect, a classified HarnessError, and an AbortError through unchanged', async () => { const defect = await failingSave(() => new Error('unrelated infrastructure failure')) expect(defect.isError).toBe(true) @@ -726,13 +730,13 @@ describe('image admission failures', () => { expect(text(result)).not.toContain('cannot read') }) - it('classifies a save failure that is not cancellation even after the caller aborts', async () => { + it('keeps a save failure that is not cancellation even after the caller aborts', async () => { const controller = new AbortController() const result = await failingSave(() => { controller.abort() return new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED') }, 'red.png', controller.signal) - expect(result.error?.info).toEqual({ name: 'ImageInputError', code: 'IMAGE_STORE_FAILED' }) + expect(result.error?.info).toEqual({ name: 'AttachmentError', code: 'ATTACHMENT_WRITE_FAILED' }) }) it('reports a missing image file and a directory target through the fs vocabulary', async () => { From 05270c6fcbe119da39dca2e351684b603445b23b Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:22:51 +0800 Subject: [PATCH 26/81] test(sandbox,terminal): drop the new assertions to unknown The ACL preparation tests match the error cause with a partial object instead of a matcher cast to unknown, and the uncancelled cleanup failure test checks the retained owner activity through the public service rather than casting the service to reach its disposal method. Refs #7869 --- packages/sandbox/sandbox-local/tests/acl-grants.spec.ts | 6 +++--- packages/terminal/terminal/tests/service.spec.ts | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index c98fbf0eb9..4f4c99c0e4 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -267,7 +267,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', message: expect.stringMatching(/workspace grant failed and its cleanup also failed/u) as string, - cause: expect.objectContaining({ errors: [mockState.addFailure, mockState.disposeFailure] }) as unknown, + cause: { errors: [mockState.addFailure, mockState.disposeFailure] }, }) expect(mockState.grants).toHaveLength(2) } finally { @@ -290,7 +290,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', message: expect.stringContaining('workspace and temp directory resolution failed') as string, - cause: expect.objectContaining({ code: 'ENOENT' }) as unknown, + cause: { code: 'ENOENT' }, }) expect(mockState.grants).toHaveLength(0) } finally { @@ -313,7 +313,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { name: 'SandboxPreparationError', code: 'SANDBOX_PREPARATION_FAILED', message: expect.stringContaining('private temp directory creation failed') as string, - cause: expect.objectContaining({ code: 'EACCES' }) as unknown, + cause: { code: 'EACCES' }, }) } finally { if (previous === undefined) delete process.env.TMPDIR diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 3199deb1a0..a43448b761 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -309,8 +309,7 @@ describe('TerminalSessionService ownership and lifecycle', () => { await expect(ctx.terminals.spawn(owner, { type: 'cleanup-failing' })).rejects.toBe(failure) expect(failure).toMatchObject({ name: 'TerminalBackendCleanupError', code: 'CLEANUP_FAILED' }) - const internal = ctx.terminals as unknown as { disposeOwned(owner: Agent): Promise } - await expect(internal.disposeOwned(owner)).rejects.toThrow('failed to clean up PTY lifecycle') + expect(ctx.terminals.hasOwnerActivity(owner)).toBe(true) }) it.each(['owner', 'service'] as const)('retains caller-triggered backend cleanup failure until %s disposal', async (scope) => { From 85c7b7be35444afca594a42c2178be373ede41c6 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:28:16 +0800 Subject: [PATCH 27/81] test(fs-local): cover readFile errno classification on every platform The Windows coverage lane left the errno branch of readFileAbortable uncovered: its only test denies reads with chmod, which is POSIX-only, and the v8 ignore did not reach the throw. readForEdit reads without a preceding stat, so a missing file and a directory exercise the branch on every platform; the ignore is removed. --- packages/fs/fs-local/src/fsio.ts | 1 - packages/fs/fs-local/tests/fsio.spec.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 63da0a95f0..c6959128a4 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -66,7 +66,6 @@ async function readFileAbortable(absolutePath: string, displayPath: string, verb try { return await readFile(absolutePath, signal ? { signal } : {}) } catch (error: unknown) { - /* v8 ignore next 2 -- a non-abort rejection needs a permission fault after a successful stat; POSIX covers it. */ if (isAbortError(error)) throw new FsError(`${verb} aborted`, 'FS_ABORTED') throw fsErrorFromErrno(verb, displayPath, error) } diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 4d4f9a3748..6c7379051d 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -981,6 +981,15 @@ describe('readForEdit + restoreLineEndings', () => { await expect(readForEdit(join(dir, 'bad'), join(dir, 'bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) }) + it('classifies a missing file and a directory read for editing', async () => { + await expect(readForEdit(join(dir, 'missing'), '~/missing')).rejects.toMatchObject({ + name: 'FsError', code: 'FS_NOT_FOUND', message: 'cannot edit "~/missing": not found', + }) + await expect(readForEdit(dir, '~/dir')).rejects.toMatchObject({ + name: 'FsError', code: 'FS_NOT_REGULAR_FILE', message: 'cannot edit "~/dir": not a regular file', + }) + }) + it('passes a live (non-aborted) signal through the read', async () => { const file = join(dir, 'a.txt') await writeFile(file, 'one\ntwo') From 3ce9462e7f8ee65e4b61578ccfc4c72b1c8e59a5 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:31:56 +0800 Subject: [PATCH 28/81] fix(terminal): keep both startup failures on TerminalBackendCleanupError's cause Renderers such as errorChain and the activation report expand AggregateError members but otherwise follow only cause, so a cause of just the setup failure hid the cleanup failure that may leave resources alive. The cause is now an AggregateError of both failures. An upgrade guide records that the class extends TerminalError instead of AggregateError. INIT_REJECTED is documented as the code a consumer that drives a shell uses when the shell rejects its setup input, and the persistent bash tool README records it. Refs #7869 --- .../guide.i18n.yaml | 16 ++++++++++++++ .../terminal-backend-cleanup-error/guide.md | 20 +++++++++++++++++ .../guide.zh.md | 20 +++++++++++++++++ .../tool-bash-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-bash-persistent/README.md | 2 +- .../shell/tool-bash-persistent/README.zh.md | 2 +- .../terminal-bash/tests/index.spec.ts | 2 +- packages/terminal/terminal/README.i18n.yaml | 8 +++---- packages/terminal/terminal/README.md | 4 ++-- packages/terminal/terminal/README.zh.md | 4 ++-- packages/terminal/terminal/src/error.ts | 22 +++++++++++-------- 11 files changed, 82 insertions(+), 22 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md diff --git a/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml new file mode 100644 index 0000000000..fc72ae7b2e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md +/: + en: 91597636f5a71894 + zh: 02d2517952d86cfd +/terminalbackendcleanuperror-extends-terminalerror: + en: cb11df977825904b + zh: 080f157e78942a9d +/terminalbackendcleanuperror-extends-terminalerror/change: + en: bf3bd46faf910ff0 + zh: fd47d6e58f467a1e +/terminalbackendcleanuperror-extends-terminalerror/migration: + en: 85fe7dd354cf8657 + zh: e39dc4ce137aeb13 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md new file mode 100644 index 0000000000..7ab32fec51 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "TerminalBackendCleanupError extends TerminalError instead of AggregateError and carries the CLEANUP_FAILED code." +--- + +# `TerminalBackendCleanupError` extends `TerminalError` + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-terminal` exports `TerminalBackendCleanupError`, which a PTY backend throws when startup failed and cleaning its partial resources also failed. It previously extended `AggregateError`. It now extends `TerminalError`, so it is a `HarnessError` with code `CLEANUP_FAILED`, and a tool result that carries it records `error.name` `TerminalBackendCleanupError` and `error.code` `CLEANUP_FAILED`. The constructor, the `name`, `spawnError`, `cleanupError`, and the `errors` list in that order are unchanged; `errors` is typed `readonly unknown[]` instead of `any[]`. `cause` is a new `AggregateError` of the same two failures. `error instanceof AggregateError` is now `false`. + +Backends and consumers that only construct the error, read `spawnError` or `cleanupError`, or test `instanceof TerminalBackendCleanupError` are unaffected. + +## Migration + +1. Replace `error instanceof AggregateError` checks meant for this error with `error instanceof TerminalBackendCleanupError`, or with `error.code === 'CLEANUP_FAILED'` on a `TerminalError`. +2. Where TypeScript code reads members of `error.errors` without narrowing, narrow each `unknown` element before use, or read `spawnError` and `cleanupError`. +3. Confirm with `pnpm exec tsc --noEmit` in the consuming project, and check that a startup cleanup failure still reaches the handling path. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md new file mode 100644 index 0000000000..7f69c4558d --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "TerminalBackendCleanupError 改为继承 TerminalError 而非 AggregateError,并带 CLEANUP_FAILED 错误码。" +--- + +# `TerminalBackendCleanupError` 改为继承 `TerminalError` + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-terminal` 导出的 `TerminalBackendCleanupError` 由 PTY 后端在启动失败且清理部分资源也失败时抛出。它原先继承 `AggregateError`,现在继承 `TerminalError`,因此是带 code `CLEANUP_FAILED` 的 `HarnessError`,携带它的工具结果会记录 `error.name` `TerminalBackendCleanupError` 和 `error.code` `CLEANUP_FAILED`。构造函数、`name`、`spawnError`、`cleanupError` 以及按原顺序排列的 `errors` 列表不变;`errors` 的类型由 `any[]` 改为 `readonly unknown[]`。`cause` 新增为同样两个失败组成的 `AggregateError`。`error instanceof AggregateError` 现在为 `false`。 + +只构造该错误、读取 `spawnError` 或 `cleanupError`、或检查 `instanceof TerminalBackendCleanupError` 的后端与消费方不受影响。 + +## 迁移 + +1. 把针对该错误的 `error instanceof AggregateError` 检查改为 `error instanceof TerminalBackendCleanupError`,或对 `TerminalError` 检查 `error.code === 'CLEANUP_FAILED'`。 +2. TypeScript 代码未经收窄就读取 `error.errors` 元素成员时,先收窄每个 `unknown` 元素,或改为读取 `spawnError` 与 `cleanupError`。 +3. 在消费项目中运行 `pnpm exec tsc --noEmit` 确认通过,并检查启动清理失败仍会进入原有的处理路径。 diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml index a7d7a29c7c..3bf961d1ca 100644 --- a/packages/shell/tool-bash-persistent/README.i18n.yaml +++ b/packages/shell/tool-bash-persistent/README.i18n.yaml @@ -27,8 +27,8 @@ en: b398f124ea7b77b0 zh: 50855e601e6c1b88 /deepseek-ai-dsh-tool-bash-persistent/use-this-package/what-can-go-wrong: - en: 52e0e1489c6f8071 - zh: 27251d61351ee72d + en: 080e71c8720eab46 + zh: 2b0266e78c868d9c /deepseek-ai-dsh-tool-bash-persistent/understand-the-implementation: en: 8d94a7381bca3fa9 zh: f4bd2866fff6fa8d diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md index 9feed7a656..3a6de11ff4 100644 --- a/packages/shell/tool-bash-persistent/README.md +++ b/packages/shell/tool-bash-persistent/README.md @@ -58,7 +58,7 @@ Commands share one shell per agent, so state persists until an `exit`, a timeout ### What can go wrong -A call without an owning agent session fails with `bash requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. +A call without an owning agent session fails with `bash requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), a shell that does not accept the echo and history setup fails with `persistent bash shell did not accept initialization` (`TerminalError`, `INIT_REJECTED`), and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command. ----- diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md index c348502a09..f74537f54e 100644 --- a/packages/shell/tool-bash-persistent/README.zh.md +++ b/packages/shell/tool-bash-persistent/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" ### 可能出什么问题 -没有拥有者 agent 会话的调用会以 `bash requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 +没有拥有者 agent 会话的调用会以 `bash requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,不接受回显与历史设置的 shell 以 `persistent bash shell did not accept initialization`(`TerminalError`,`INIT_REJECTED`)失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。 ----- diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 2859644622..47cca862e9 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -162,7 +162,7 @@ describe('BashTerminalBackend startup rollback', () => { spawnError: startupFailure, cleanupError: cleanupFailure, errors: [startupFailure, cleanupFailure], - cause: startupFailure, + cause: expect.objectContaining({ errors: [startupFailure, cleanupFailure] }) as AggregateError, } satisfies Partial)) }) diff --git a/packages/terminal/terminal/README.i18n.yaml b/packages/terminal/terminal/README.i18n.yaml index e734b14bf2..9f24300c84 100644 --- a/packages/terminal/terminal/README.i18n.yaml +++ b/packages/terminal/terminal/README.i18n.yaml @@ -45,11 +45,11 @@ en: a2adfa2708882e19 zh: 002f257fd8c4addb /deepseek-ai-dsh-terminal/understand-the-implementation/ownership-and-cleanup-rules: - en: b669a68c739b062a - zh: cc641f850905095f + en: 3c977ec7192623b1 + zh: 78d14a23e087808a /deepseek-ai-dsh-terminal/understand-the-implementation/send-reservation: - en: 0b8dc3a382aa7bf2 - zh: e6cc6906e01827d2 + en: b80a540215116c43 + zh: 1dc651d09ceb4b15 /deepseek-ai-dsh-terminal/further-exploration: en: 8459a3b70a9f8bad zh: 21372aa8939d2182 diff --git a/packages/terminal/terminal/README.md b/packages/terminal/terminal/README.md index ff34d9a3cc..ac88814518 100644 --- a/packages/terminal/terminal/README.md +++ b/packages/terminal/terminal/README.md @@ -84,12 +84,12 @@ Each published session is a record of its id, owner, optional name, backend type ### Ownership and cleanup rules - Fencing uses the exact `Agent` object: `hasOwnerActivity(owner)` spans unpublished setup through final close with no publication gap, so lifecycle policy can fence the owner precisely. -- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`, a `TerminalError` with code `CLEANUP_FAILED` that lists both failures in `errors`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. +- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`, a `TerminalError` with code `CLEANUP_FAILED` that lists both failures in `errors` and in an `AggregateError` `cause`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. - Caller cancellation keeps its exact `AbortSignal.reason`; `kill()` and disposal resolve only after the backend's captured process tree is quiescent. ### Send reservation -The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. Failures are `TerminalError` instances (a `HarnessError`); besides the registry codes, a send to a closing session fails with `SESSION_CLOSING`, and backends report `SESSION_EXITED`, `STARTUP_EXITED`, `STARTUP_TIMEOUT`, `NO_SANDBOX`, and `INIT_REJECTED` with the same class. +The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. Failures are `TerminalError` instances (a `HarnessError`); besides the registry codes, a send to a closing session fails with `SESSION_CLOSING`, and backends report `SESSION_EXITED`, `STARTUP_EXITED`, `STARTUP_TIMEOUT`, and `NO_SANDBOX` with the same class. A consumer that drives a shell uses `INIT_REJECTED` when that shell rejects the consumer's setup input. diff --git a/packages/terminal/terminal/README.zh.md b/packages/terminal/terminal/README.zh.md index cef5a62110..07022207e5 100644 --- a/packages/terminal/terminal/README.zh.md +++ b/packages/terminal/terminal/README.zh.md @@ -84,12 +84,12 @@ kind: "package-reference" ### 所有权与清理规则 - 限制基于确切的 `Agent` 对象:`hasOwnerActivity(owner)` 覆盖从尚未发布的设置到最终关闭的全过程,没有发布竞态,因此生命周期策略可以精确限制所有者。 -- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝,它是 code 为 `CLEANUP_FAILED` 的 `TerminalError`,并在 `errors` 中列出两个失败;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 +- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝,它是 code 为 `CLEANUP_FAILED` 的 `TerminalError`,在 `errors` 和 `AggregateError` 类型的 `cause` 中列出两个失败;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 - 调用方取消保留其确切的 `AbortSignal.reason`;`kill()` 与 dispose 只在后端捕获的进程树完全停稳后完成。 ### 发送预留 -服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。失败均为 `TerminalError`(一种 `HarnessError`);除注册表 code 外,向正在关闭的会话发送会以 `SESSION_CLOSING` 失败,后端以同一类报告 `SESSION_EXITED`、`STARTUP_EXITED`、`STARTUP_TIMEOUT`、`NO_SANDBOX` 与 `INIT_REJECTED`。 +服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。失败均为 `TerminalError`(一种 `HarnessError`);除注册表 code 外,向正在关闭的会话发送会以 `SESSION_CLOSING` 失败,后端以同一类报告 `SESSION_EXITED`、`STARTUP_EXITED`、`STARTUP_TIMEOUT` 与 `NO_SANDBOX`。通过服务驱动 shell 的消费方在该 shell 拒绝自身的初始化输入时使用 `INIT_REJECTED`。 diff --git a/packages/terminal/terminal/src/error.ts b/packages/terminal/terminal/src/error.ts index b487ac9bc4..71db13687c 100644 --- a/packages/terminal/terminal/src/error.ts +++ b/packages/terminal/terminal/src/error.ts @@ -4,13 +4,14 @@ import { HarnessError } from '@deepseek-ai/dsh-error' /** * Machine-routable PTY service and backend failures. Backends use - * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a - * confined mode has no sandbox provider, `SESSION_CLOSING` and - * `SESSION_EXITED` for an operation on a closing or exited session, - * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses - * readiness during startup, and `CLEANUP_FAILED` (always as + * `NO_SANDBOX` when a confined mode has no sandbox provider, + * `SESSION_CLOSING` and `SESSION_EXITED` for an operation on a closing or + * exited session, `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits + * or misses readiness during startup, and `CLEANUP_FAILED` (always as * {@link TerminalBackendCleanupError}) when startup failed and the partial - * resources it left could not be cleaned. + * resources it left could not be cleaned. A consumer that drives a shell + * through the service uses `INIT_REJECTED` when that shell rejects the + * consumer's own setup input. */ export type TerminalErrorCode = | 'CLEANUP_FAILED' @@ -48,8 +49,9 @@ export class TerminalError extends HarnessError { /** * Backend-reported failure to clean partial resources after unpublished setup * failed; code `CLEANUP_FAILED`. Backend-owned resources may still be alive. - * `errors` lists both failures in the order `AggregateError` would, and - * `cause` is the setup failure. + * `errors` lists the setup failure, then the cleanup failure; `cause` is an + * `AggregateError` of the same two failures, so renderers that follow `cause` + * report both. */ export class TerminalBackendCleanupError extends TerminalError { /** The setup failure followed by the cleanup failure. */ @@ -63,7 +65,9 @@ export class TerminalBackendCleanupError extends TerminalError { readonly spawnError: unknown, readonly cleanupError: unknown, ) { - super('PTY backend startup and cleanup both failed', 'CLEANUP_FAILED', { cause: spawnError }) + super('PTY backend startup and cleanup both failed', 'CLEANUP_FAILED', { + cause: new AggregateError([spawnError, cleanupError], 'PTY backend startup and cleanup both failed'), + }) this.name = 'TerminalBackendCleanupError' this.errors = [spawnError, cleanupError] } From 088d1f3b0405fe85650eaf298321ab4fa7767eb9 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:38:50 +0800 Subject: [PATCH 29/81] fix(shell): classify only errno spawn failures and real cancellations subprocessSpawnFailure() now converts only errors that carry a string errno code and returns every other value unchanged, so an internal failure such as a dropped collect stream is no longer reported as cannot start / SUBPROCESS_SPAWN_FAILED. The bash and pwsh tools pass a rejection through unchanged only when it is the call's own cancellation reason; a spawn error that settles after the call was cancelled is still classified. The job starter reads the failure through an optional hooks binding instead of a definite-assignment assertion. Refs #7869 --- packages/shell/tool-bash/README.i18n.yaml | 4 +-- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/src/index.ts | 25 ++++++++++--------- packages/shell/tool-bash/tests/tools.spec.ts | 16 ++++++++++++ packages/shell/tool-pwsh/README.i18n.yaml | 4 +-- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/src/index.ts | 25 ++++++++++--------- .../shell/tool-pwsh/tests/background.spec.ts | 4 +-- packages/shell/tool-pwsh/tests/tools.spec.ts | 16 ++++++++++++ .../subprocess/subprocess/README.i18n.yaml | 4 +-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/error.ts | 22 +++++++--------- .../subprocess/subprocess/tests/error.spec.ts | 11 +++++--- 16 files changed, 89 insertions(+), 54 deletions(-) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index 625910149b..95fd6ed161 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -111,8 +111,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-bash/model-experience/tool-errors/what-the-model-sees: - en: f4cebcfd09cd3b13 - zh: 4e3954f417b4a9ed + en: 1772eedea1c1ee6a + zh: a0d77bdd515cb517 /deepseek-ai-dsh-tool-bash/model-experience/tool-errors/token-effect: en: a3a78ef476ec5e56 zh: dd2c8ad0f69e8ff1 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 411d5da79f..7c1a498b13 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -196,7 +196,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`). +Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the approval availability/rejection/cancellation variants, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error, recognized by its errno `code`, becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`), even when it settles after the call was cancelled. #### Token effect diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 7f222c91aa..1e0f96c57f 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -196,7 +196,7 @@ renderer 输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` #### 模型看到什么 -验证与策略失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。 +验证与策略失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、审批不可用/拒绝/取消变体,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;按 errno `code` 识别的平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`),在调用取消后才出现时也是如此。 #### Token 影响 diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index 46554fc192..f3e8b14eb2 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -30,6 +30,7 @@ import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' import { processJob, processOutcome, processSources, ringDelta } from './background.ts' +import type { ProcessJobHooks } from './background.ts' import { parseExitStatus, renderJobRead, renderPromoted, renderResult } from './render.ts' export const name = 'tool-bash' @@ -188,13 +189,14 @@ function toolAborted(): HarnessError { } /** - * Report a launch or preparation rejection as this tool's failure. A coded - * error keeps its class and code; this call's cancellation stays as thrown so - * the registry records it as aborted; any other value is classified as a - * subprocess spawn failure with the original as `cause`. + * Report a launch or preparation rejection as this tool's failure. This + * call's cancellation reason stays as thrown so the registry records it as + * aborted; a platform spawn error is classified as a subprocess spawn failure + * with the original as `cause`; any other value, including a coded error, + * keeps its own identity. */ function launchFailure(error: unknown, signal: AbortSignal): unknown { - return signal.aborted ? error : subprocessSpawnFailure(error, 'bash') + return signal.aborted && error === signal.reason ? error : subprocessSpawnFailure(error, 'bash') } /** A registered command: its id, the process once the starter spawned it, and the reason an outside kill gave. */ @@ -283,8 +285,7 @@ export function apply(ctx: Context, config: Config = {}): void { const startJob = (registry: JobRegistry, args: BashToolArgs, exec: ToolExecution, spec: ShellExecSpec): StartedJob => { let proc: ShellExecution | undefined let stopped: string | undefined - // The registry runs the starter inside start(), so this is assigned before start() returns. - let failure!: () => { error: unknown } | undefined + let hooks: ProcessJobHooks | undefined const id = registry.start({ kind: 'bash', label: args.command, @@ -292,26 +293,26 @@ export function apply(ctx: Context, config: Config = {}): void { ...exec.agent ? { owner: exec.agent.id } : {}, output: processSources(() => proc), run: () => { - const hooks = processJob( + const job = processJob( async (signal) => { proc = await ctx.shell.execute({ ...spec, signal }) return proc }, started => processOutcome(started, escalationModes), ) - failure = () => hooks.failure() + hooks = job return { - done: hooks.done, + done: job.done, // A kill from outside this call (the human's, a parallel job_kill) // reaches the process here; its reason is what the model reads. cancel: (reason) => { stopped = reason - hooks.cancel(reason) + job.cancel(reason) }, } }, }) - return { id, process: () => proc, stopped: () => stopped, failure: () => failure() } + return { id, process: () => proc, stopped: () => stopped, failure: () => hooks?.failure() } } /** Wait on a registered foreground command until it settles or the timeout passes. */ const waitOnJob = async (registry: JobRegistry, attached: StartedJob, exec: ToolExecution, spec: ShellExecSpec) => { diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 554b7f4e35..45e92e05d5 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -1576,4 +1576,20 @@ describe('launch failures without a job registry', () => { }) expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } }) }) + + it('classifies a spawn error that settles after this call was cancelled', async () => { + const ctx = await setup() + const controller = new AbortController() + vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => { + controller.abort(new Error('caller stopped')) + return Promise.reject(Object.assign(new Error('spawn bash ENOENT'), { code: 'ENOENT' })) + }) + const result = await executeToolFixture(ctx, { + signal: controller.signal, + callId: ToolCallId('launch-aborted-spawn-error'), + name: 'bash', + arguments: { command: 'true', description: 'd' }, + }) + expect(result).toMatchObject({ isError: true, error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } } }) + }) }) diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index 8032f1b349..e4c8f16b4b 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -108,8 +108,8 @@ en: 2d4f2c8605eccab3 zh: 67f1f0218f73c6bf /deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/what-the-model-sees: - en: d45b05d98cf6e7e3 - zh: b3c59fb3603f511e + en: c71e7f547de19d3d + zh: a9d9c3a84e1618b9 /deepseek-ai-dsh-tool-pwsh/model-experience/tool-errors/token-effect: en: 243bad012dd99c7f zh: 5ee74905020bd49a diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index 34bc941529..ff35a8068d 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -188,7 +188,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`). +Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid arguments: invalid command: expected a non-empty string`, `invalid arguments: invalid description: expected a non-empty string`, `invalid arguments: invalid timeoutMs: expected a positive number, got `, the escalation pairing failures, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`, and `tool call aborted`. Each failure also records `error.name` and `error.code` on `tool/result`: argument violations are `ToolArgsError` (`INVALID_ARGS`, message prefix `invalid arguments: `), escalation failures are `SandboxEscalationError` (`SANDBOX_ESCALATION_INVALID_ARGUMENT`, `_UNAVAILABLE`, `_REJECTED`, `_CANCELLED`), a disabled background call is `JobError` (`JOB_BACKGROUND_DISABLED`), a missing job runtime is `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`), and cancellation is `AbortError` (`ABORTED`). A launch or preparation failure keeps the class and code it was thrown with, such as `SandboxUnavailableError` or `SandboxPreparationError`, whether the call waited on a job or not; a platform spawn error, recognized by its errno `code`, becomes `SubprocessError` (`SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT`, otherwise `SUBPROCESS_SPAWN_FAILED`), even when it settles after the call was cancelled. #### Token effect diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index f81c92a602..6225e8ad33 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -188,7 +188,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be #### 模型看到什么 -验证与基础设施失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`)。 +验证与基础设施失败统一为 `Error: `。本包的稳定消息包括 `invalid arguments: invalid command: expected a non-empty string`、`invalid arguments: invalid description: expected a non-empty string`、`invalid arguments: invalid timeoutMs: expected a positive number, got `、升权配对失败、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享升权失败(未严格加宽/无审批服务/无 agent 可路由/无审批通道/用户拒绝/已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs`,以及 `tool call aborted`。每个失败还会在 `tool/result` 上记录 `error.name` 与 `error.code`:参数违规为 `ToolArgsError`(`INVALID_ARGS`,消息前缀为 `invalid arguments: `),升权失败为 `SandboxEscalationError`(`SANDBOX_ESCALATION_INVALID_ARGUMENT`、`_UNAVAILABLE`、`_REJECTED`、`_CANCELLED`),被禁用的后台调用为 `JobError`(`JOB_BACKGROUND_DISABLED`),缺少任务运行时为 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`),取消为 `AbortError`(`ABORTED`)。启动或准备阶段的失败保留其抛出时的类与 code(例如 `SandboxUnavailableError` 或 `SandboxPreparationError`),无论调用是否在 job 上等待;按 errno `code` 识别的平台 spawn 错误转为 `SubprocessError`(`ENOENT` 为 `SUBPROCESS_SPAWN_NOT_FOUND`,其余为 `SUBPROCESS_SPAWN_FAILED`),在调用取消后才出现时也是如此。 #### Token 影响 diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index 0ef0fa260b..1a5eac1e7d 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -39,6 +39,7 @@ import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' import { processJob, processOutcome, processSources, ringDelta } from './background.ts' +import type { ProcessJobHooks } from './background.ts' import { renderPwshJobRead, renderPwshPromoted, renderPwshResult } from './render.ts' import type { RenderablePwshResult } from './render.ts' @@ -196,13 +197,14 @@ function toolAborted(): HarnessError { } /** - * Report a launch or preparation rejection as this tool's failure. A coded - * error keeps its class and code; this call's cancellation stays as thrown so - * the registry records it as aborted; any other value is classified as a - * subprocess spawn failure with the original as `cause`. + * Report a launch or preparation rejection as this tool's failure. This + * call's cancellation reason stays as thrown so the registry records it as + * aborted; a platform spawn error is classified as a subprocess spawn failure + * with the original as `cause`; any other value, including a coded error, + * keeps its own identity. */ function launchFailure(error: unknown, signal: AbortSignal): unknown { - return signal.aborted ? error : subprocessSpawnFailure(error, 'pwsh') + return signal.aborted && error === signal.reason ? error : subprocessSpawnFailure(error, 'pwsh') } /** A registered command: its id, the process once the starter spawned it, and the reason an outside kill gave. */ @@ -298,8 +300,7 @@ export function apply(ctx: Context, config: Config = {}): void { const startJob = (registry: JobRegistry, args: PwshToolArgs, exec: ToolExecution, spec: ShellExecSpec): StartedJob => { let proc: ShellExecution | undefined let stopped: string | undefined - // The registry runs the starter inside start(), so this is assigned before start() returns. - let failure!: () => { error: unknown } | undefined + let hooks: ProcessJobHooks | undefined const id = registry.start({ kind: 'pwsh', label: args.command, @@ -307,26 +308,26 @@ export function apply(ctx: Context, config: Config = {}): void { ...exec.agent ? { owner: exec.agent.id } : {}, output: processSources(() => proc), run: () => { - const hooks = processJob( + const job = processJob( async (signal) => { proc = await ctx.shell.execute({ ...spec, signal }) return proc }, started => processOutcome(started, escalationModes), ) - failure = () => hooks.failure() + hooks = job return { - done: hooks.done, + done: job.done, // A kill from outside this call (the human's, a parallel job_kill) // reaches the process here; its reason is what the model reads. cancel: (reason) => { stopped = reason - hooks.cancel(reason) + job.cancel(reason) }, } }, }) - return { id, process: () => proc, stopped: () => stopped, failure: () => failure() } + return { id, process: () => proc, stopped: () => stopped, failure: () => hooks?.failure() } } /** Wait on a registered foreground command until it settles or the timeout passes. */ const waitOnJob = async (registry: JobRegistry, attached: StartedJob, exec: ToolExecution, spec: ShellExecSpec) => { diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 48b01dde16..870325ab91 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -345,13 +345,13 @@ describe('foreground commands as jobs (pwsh)', () => { it('a command whose start fails reports the failure as an error result and leaves no job behind', async () => { const { ctx, pwsh } = await setup() - pwsh.backgroundHandler = () => { throw new Error('spawn pwsh ENOENT') } + pwsh.backgroundHandler = () => { throw Object.assign(new Error('spawn pwsh EACCES'), { code: 'EACCES' }) } const result = await call(ctx, { command: 'Get-Missing', description: 'test command' }) expect(result).toMatchObject({ isError: true, error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_FAILED' } }, }) - expect((result.content[0] as { text: string }).text).toBe('Error: cannot start "pwsh": spawn pwsh ENOENT') + expect((result.content[0] as { text: string }).text).toBe('Error: cannot start "pwsh": spawn pwsh EACCES') expect(ctx.jobs.list()).toEqual([]) }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 00fb01724f..7db5c6fc5d 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -1317,4 +1317,20 @@ describe('launch failures without a job registry', () => { }) expect(aborted).toMatchObject({ isError: true, error: { info: { name: 'AbortError', code: TOOL_ABORTED } } }) }) + + it('classifies a spawn error that settles after this call was cancelled', async () => { + const { ctx } = await setup() + const controller = new AbortController() + vi.spyOn(ctx.shell, 'execute').mockImplementationOnce(() => { + controller.abort(new Error('caller stopped')) + return Promise.reject(Object.assign(new Error('spawn pwsh ENOENT'), { code: 'ENOENT' })) + }) + const result = await executeToolFixture(ctx, { + signal: controller.signal, + callId: ToolCallId('launch-aborted-spawn-error'), + name: 'pwsh', + arguments: { command: 'Write-Output ok', description: 'd' }, + }) + expect(result).toMatchObject({ isError: true, error: { info: { name: 'SubprocessError', code: 'SUBPROCESS_SPAWN_NOT_FOUND' } } }) + }) }) diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 762893c5a0..8f829ef784 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -39,8 +39,8 @@ en: 4d4cbb311575d071 zh: 7be2d265adb661bc /deepseek-ai-dsh-subprocess/use-this-package/what-can-go-wrong: - en: 718494d6530da700 - zh: a41b7d48a9d12178 + en: 819a0d9b6ac823ab + zh: 57be33bc8204dd3e /deepseek-ai-dsh-subprocess/understand-the-implementation: en: 25cd344c959cfd9e zh: 045880776ab82d1e diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 337afd9fba..f94817fa4d 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -81,7 +81,7 @@ Children never inherit the harness's ambient secrets: credential-shaped names an ### What can go wrong -Coded failures are `SubprocessError` instances whose `code` is one of `SUBPROCESS_ERROR_CODES`: an empty argv is `SUBPROCESS_INVALID_ARGV`, an executable lookup miss is `SubprocessExecutableNotFoundError` (`SUBPROCESS_EXECUTABLE_NOT_FOUND`), a provider's private runner that fails or breaks its channel is `SUBPROCESS_RUNNER_FAILED` or `SUBPROCESS_RUNNER_IPC_FAILED`, and an unloadable terminal implementation is `SUBPROCESS_PTY_UNAVAILABLE`. A spawn requested on an already aborted signal throws an error named `AbortError`. A spawn that never starts rejects `done` with the platform's original error, so consumers can inspect its errno fields; a consumer that reports the rejection as its own failure converts it with `subprocessSpawnFailure()`, which yields `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` otherwise. There is no buffered output for a process that never ran. `waitForExit()` also rejects when the provider cannot prove its selected range is empty, and a provider fallback may not own descendants that escape its process group or observed session. When a transport owns its own spawn (the SDK client, MCP), route around the service and import `scrubbedParentEnv` directly so environment policy stays single-sourced. +Coded failures are `SubprocessError` instances whose `code` is one of `SUBPROCESS_ERROR_CODES`: an empty argv is `SUBPROCESS_INVALID_ARGV`, an executable lookup miss is `SubprocessExecutableNotFoundError` (`SUBPROCESS_EXECUTABLE_NOT_FOUND`), a provider's private runner that fails or breaks its channel is `SUBPROCESS_RUNNER_FAILED` or `SUBPROCESS_RUNNER_IPC_FAILED`, and an unloadable terminal implementation is `SUBPROCESS_PTY_UNAVAILABLE`. A spawn requested on an already aborted signal throws an error named `AbortError`. A spawn that never starts rejects `done` with the platform's original error, so consumers can inspect its errno fields; a consumer that reports the rejection as its own failure converts it with `subprocessSpawnFailure()`, which turns an error with a string errno `code` into `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` for any other errno, and returns every other value unchanged. There is no buffered output for a process that never ran. `waitForExit()` also rejects when the provider cannot prove its selected range is empty, and a provider fallback may not own descendants that escape its process group or observed session. When a transport owns its own spawn (the SDK client, MCP), route around the service and import `scrubbedParentEnv` directly so environment policy stays single-sourced. ----- diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index da111f517a..90cabb9c87 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -81,7 +81,7 @@ const output = handle.collected.stdout?.readFrom(0) ### 可能出错的地方 -带 code 的失败都是 `SubprocessError`,其 `code` 属于 `SUBPROCESS_ERROR_CODES`:argv 为空是 `SUBPROCESS_INVALID_ARGV`,可执行文件查找未命中是 `SubprocessExecutableNotFoundError`(`SUBPROCESS_EXECUTABLE_NOT_FOUND`),提供方私有 runner 失败或通道损坏是 `SUBPROCESS_RUNNER_FAILED` 或 `SUBPROCESS_RUNNER_IPC_FAILED`,终端实现无法加载是 `SUBPROCESS_PTY_UNAVAILABLE`。在已 abort 的 signal 上请求 spawn 会抛出名为 `AbortError` 的错误。从未启动成功的 spawn 会让 `done` 以平台原始错误 reject,便于消费者检查 errno 字段;把该 reject 报告为自身失败的消费者用 `subprocessSpawnFailure()` 转换,`ENOENT` 得到 `SUBPROCESS_SPAWN_NOT_FOUND`,其余得到 `SUBPROCESS_SPAWN_FAILED`。从未运行过的进程没有任何缓冲输出。提供方无法证明所选范围为空时,`waitForExit()` 也会 reject;提供方 fallback 可能无法拥有逃离其进程组或已观察会话的后代。当传输拥有自己的 spawn(SDK 客户端、MCP)时,请绕开本服务并直接导入 `scrubbedParentEnv`,让环境策略保持单一来源。 +带 code 的失败都是 `SubprocessError`,其 `code` 属于 `SUBPROCESS_ERROR_CODES`:argv 为空是 `SUBPROCESS_INVALID_ARGV`,可执行文件查找未命中是 `SubprocessExecutableNotFoundError`(`SUBPROCESS_EXECUTABLE_NOT_FOUND`),提供方私有 runner 失败或通道损坏是 `SUBPROCESS_RUNNER_FAILED` 或 `SUBPROCESS_RUNNER_IPC_FAILED`,终端实现无法加载是 `SUBPROCESS_PTY_UNAVAILABLE`。在已 abort 的 signal 上请求 spawn 会抛出名为 `AbortError` 的错误。从未启动成功的 spawn 会让 `done` 以平台原始错误 reject,便于消费者检查 errno 字段;把该 reject 报告为自身失败的消费者用 `subprocessSpawnFailure()` 转换:带字符串 errno `code` 的错误中,`ENOENT` 得到 `SUBPROCESS_SPAWN_NOT_FOUND`,其余 errno 得到 `SUBPROCESS_SPAWN_FAILED`;其他值原样返回。从未运行过的进程没有任何缓冲输出。提供方无法证明所选范围为空时,`waitForExit()` 也会 reject;提供方 fallback 可能无法拥有逃离其进程组或已观察会话的后代。当传输拥有自己的 spawn(SDK 客户端、MCP)时,请绕开本服务并直接导入 `scrubbedParentEnv`,让环境策略保持单一来源。 ----- diff --git a/packages/subprocess/subprocess/src/error.ts b/packages/subprocess/subprocess/src/error.ts index febc495b63..df6acbec18 100644 --- a/packages/subprocess/subprocess/src/error.ts +++ b/packages/subprocess/subprocess/src/error.ts @@ -61,21 +61,17 @@ export class SubprocessExecutableNotFoundError extends SubprocessError { * `cause`. * @param error - the provider's rejection value. * @param program - the program the consumer asked to spawn, named in the message. - * @returns `error` unchanged when it is a `HarnessError` or a cancellation - * (`name === 'AbortError'`); otherwise a {@link SubprocessError} with - * `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and `SUBPROCESS_SPAWN_FAILED` for - * any other value. + * @returns a {@link SubprocessError} for a platform spawn error, recognized by + * its string errno `code`: `SUBPROCESS_SPAWN_NOT_FOUND` for `ENOENT` and + * `SUBPROCESS_SPAWN_FAILED` for any other errno. Every other value, including + * a `HarnessError`, is returned unchanged, because it is not a spawn failure + * this helper can classify. */ export function subprocessSpawnFailure(error: unknown, program: string): unknown { - if (error instanceof HarnessError) return error - if (error instanceof Error && error.name === 'AbortError') return error - const errno = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined - let detail = 'unprintable failure' - try { - detail = error instanceof Error ? error.message : String(error) - } catch { - // A provider-owned rejection whose conversion throws still yields a coded failure. - } + if (!(error instanceof Error) || error instanceof HarnessError) return error + const errno = (error as { code?: unknown }).code + if (typeof errno !== 'string') return error + const detail = error.message if (errno === 'ENOENT') { return new SubprocessError( `cannot start ${JSON.stringify(program)}: the program or the working directory does not exist (${detail})`, diff --git a/packages/subprocess/subprocess/tests/error.spec.ts b/packages/subprocess/subprocess/tests/error.spec.ts index dbb0554043..4916e05c06 100644 --- a/packages/subprocess/subprocess/tests/error.spec.ts +++ b/packages/subprocess/subprocess/tests/error.spec.ts @@ -34,9 +34,14 @@ describe('subprocessSpawnFailure', () => { }) const denied = Object.assign(new Error('spawn bash EACCES'), { code: 'EACCES' }) expect(subprocessSpawnFailure(denied, 'bash')).toMatchObject({ code: 'SUBPROCESS_SPAWN_FAILED', cause: denied }) - expect(subprocessSpawnFailure('raw', 'bash')).toMatchObject({ code: 'SUBPROCESS_SPAWN_FAILED', message: 'cannot start "bash": raw' }) - const unprintable = { [Symbol.toPrimitive]() { throw new Error('no') } } - expect(subprocessSpawnFailure(unprintable, 'bash')).toMatchObject({ message: 'cannot start "bash": unprintable failure' }) + }) + + it('returns values without an errno code unchanged', () => { + const internal = new Error('subprocess implementation dropped a requested collect stream') + expect(subprocessSpawnFailure(internal, 'bash')).toBe(internal) + const numeric = Object.assign(new Error('exit'), { code: 1 }) + expect(subprocessSpawnFailure(numeric, 'bash')).toBe(numeric) + expect(subprocessSpawnFailure('raw', 'bash')).toBe('raw') }) it('returns coded errors and cancellations unchanged', () => { From 449addf2c9cafd7c76656fb03ff9ec698c4d7468 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:42:23 +0800 Subject: [PATCH 30/81] fix(jobs): report a missing owner registry with a JobError code A job registration that names an owner in a composition without an Agent registry now throws JobError JOB_OWNER_REGISTRY_UNAVAILABLE instead of the tool-layer ToolContextUnavailableError: the registry is a service whose start() also has non-tool callers. jobs-local no longer depends on dsh-tools. Refs #7869 --- docs/config-catalog.md | 6 +++--- docs/config-catalog.zh.md | 6 +++--- docs/module-graph.md | 11 +++++------ docs/module-graph.zh.md | 11 +++++------ packages/jobs/jobs-local/README.i18n.yaml | 4 ++-- packages/jobs/jobs-local/README.md | 2 +- packages/jobs/jobs-local/README.zh.md | 2 +- packages/jobs/jobs-local/package.json | 6 ++---- packages/jobs/jobs-local/src/index.ts | 3 +-- packages/jobs/jobs-local/tests/jobs.spec.ts | 2 +- packages/jobs/jobs-local/tsconfig.json | 3 --- packages/jobs/jobs/src/error.ts | 2 ++ pnpm-lock.yaml | 3 --- 13 files changed, 26 insertions(+), 35 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f0e447ae59..7f4507050d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1891,7 +1891,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:47`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -4083,7 +4083,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:39`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:40`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4348,7 +4348,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:56`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:57`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dad8488a45..167e434a7b 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1893,7 +1893,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:47`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -4085,7 +4085,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:39`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:40`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4350,7 +4350,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:56`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:57`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ diff --git a/docs/module-graph.md b/docs/module-graph.md index dbd423430a..065d448df7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -808,6 +808,10 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_typert_protocol pkg_permission_presets --> pkg_user_approval + pkg_jobs_local --> pkg_agent + pkg_jobs_local --> pkg_jobs + pkg_jobs_local --> pkg_scope + pkg_jobs_local --> pkg_timeout pkg_ptc_runtime_node --> pkg_fs pkg_ptc_runtime_node --> pkg_ptc_runtime pkg_ptc_runtime_node --> pkg_sandbox @@ -1023,11 +1027,6 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions - pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_jobs - pkg_jobs_local --> pkg_scope - pkg_jobs_local --> pkg_timeout - pkg_jobs_local --> pkg_tools pkg_tool_jobs --> pkg_agent pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm @@ -1803,6 +1802,7 @@ flowchart TD | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node) | `ptc-runtime` | [`fs`](../packages/fs/fs), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | @@ -1845,7 +1845,6 @@ flowchart TD | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index caf5f595f8..f60f9a4e3d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -810,6 +810,10 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_typert_protocol pkg_permission_presets --> pkg_user_approval + pkg_jobs_local --> pkg_agent + pkg_jobs_local --> pkg_jobs + pkg_jobs_local --> pkg_scope + pkg_jobs_local --> pkg_timeout pkg_ptc_runtime_node --> pkg_fs pkg_ptc_runtime_node --> pkg_ptc_runtime pkg_ptc_runtime_node --> pkg_sandbox @@ -1025,11 +1029,6 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions - pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_jobs - pkg_jobs_local --> pkg_scope - pkg_jobs_local --> pkg_timeout - pkg_jobs_local --> pkg_tools pkg_tool_jobs --> pkg_agent pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm @@ -1805,6 +1804,7 @@ flowchart TD | [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node) | `ptc-runtime` | [`fs`](../packages/fs/fs), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | @@ -1847,7 +1847,6 @@ flowchart TD | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index 93045928da..746d8cbf72 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -33,8 +33,8 @@ en: ae926795d8aaa88f zh: 0e6ec0265eefccf7 /deepseek-ai-dsh-jobs-local/use-this-package/what-can-go-wrong: - en: 3737bc2cfa7f7012 - zh: 07bc090cbfa44584 + en: 5600eb21a76970ee + zh: 66bf8fab3f380115 /deepseek-ai-dsh-jobs-local/understand-the-implementation: en: f782557ca8da3138 zh: e8f0082b9692cacc diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index 1f753e7f93..7f7b5b1401 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -64,7 +64,7 @@ Lifecycle facts are appended synchronously at the registration or settlement com ### What can go wrong -Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. Refusals are `JobError` instances: `JOB_CONTROLLER_UNAVAILABLE`, `JOB_LIMIT_REACHED`, `JOB_OWNER_NOT_LIVE`, `JOB_NOT_FOUND`, and `JOB_FOREIGN_SESSION`; an owned registration without the agent registry throws `ToolContextUnavailableError`. A wait whose signal aborts rejects with an error named `AbortError`. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Live registry records disappear when the harness process exits; persisted lifecycle events remain. +Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. Refusals are `JobError` instances: `JOB_CONTROLLER_UNAVAILABLE`, `JOB_LIMIT_REACHED`, `JOB_OWNER_NOT_LIVE`, `JOB_NOT_FOUND`, and `JOB_FOREIGN_SESSION`; an owned registration without the agent registry throws `JOB_OWNER_REGISTRY_UNAVAILABLE`. A wait whose signal aborts rejects with an error named `AbortError`. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Live registry records disappear when the harness process exits; persisted lifecycle events remain. ----- diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index 30dc26d3b0..ee2ff3c879 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 可能出什么问题 -没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。拒绝均为 `JobError`:`JOB_CONTROLLER_UNAVAILABLE`、`JOB_LIMIT_REACHED`、`JOB_OWNER_NOT_LIVE`、`JOB_NOT_FOUND` 与 `JOB_FOREIGN_SESSION`;没有 agent 注册表时登记带所有者的 job 会抛出 `ToolContextUnavailableError`。signal 中止的等待以名为 `AbortError` 的错误拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。运行期注册表记录会在 harness 进程退出时消失;已持久化的生命周期事件会保留。 +没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。拒绝均为 `JobError`:`JOB_CONTROLLER_UNAVAILABLE`、`JOB_LIMIT_REACHED`、`JOB_OWNER_NOT_LIVE`、`JOB_NOT_FOUND` 与 `JOB_FOREIGN_SESSION`;没有 agent 注册表时登记带所有者的 job 会以 `JOB_OWNER_REGISTRY_UNAVAILABLE` 失败。signal 中止的等待以名为 `AbortError` 的错误拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。运行期注册表记录会在 harness 进程退出时消失;已持久化的生命周期事件会保留。 ----- diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index b7e73d4753..b635f6289d 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -36,8 +36,7 @@ "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -52,7 +51,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 4e1a0959f6..378aaf0c08 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -18,7 +18,6 @@ import { ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { SessionId } from '@deepseek-ai/dsh-session' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobError, JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' -import { ToolContextUnavailableError } from '@deepseek-ai/dsh-tools' import type { JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKind, JobOutcome, JobOutputRead, JobOutputSource, JobRead, JobRegisteredData, JobSettleCause, JobSettledData, JobSpec, JobStatus, JobToolCall, JobView, @@ -374,7 +373,7 @@ export class LocalJobRegistry extends JobRegistry { if (session === undefined) return undefined const agents = this.selfCtx.get('agents') if (agents === undefined) { - throw new ToolContextUnavailableError('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + throw new JobError('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)', 'JOB_OWNER_REGISTRY_UNAVAILABLE') } const owner = agents.get(session) if (owner === undefined) { diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 521c7647d6..f3d47532f2 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -768,7 +768,7 @@ describe('LocalJobRegistry owner isolation', () => { expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) .toThrow('background job ownership requires the agent registry') expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) - .toThrow(expect.objectContaining({ name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' })) + .toThrow(expect.objectContaining(jobError('JOB_OWNER_REGISTRY_UNAVAILABLE'))) // The failed registration mutated nothing: no stored job. expect(ctx.jobs.list()).toEqual([]) expect(ctx.jobs.start(producer().spec)).toMatch(/^bash-[0-9a-f]{16}$/) diff --git a/packages/jobs/jobs-local/tsconfig.json b/packages/jobs/jobs-local/tsconfig.json index 892c4da43e..f94627dd8f 100644 --- a/packages/jobs/jobs-local/tsconfig.json +++ b/packages/jobs/jobs-local/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../jobs" - }, - { - "path": "../../core/tools" } ] } diff --git a/packages/jobs/jobs/src/error.ts b/packages/jobs/jobs/src/error.ts index e0f0af64d9..28c2984382 100644 --- a/packages/jobs/jobs/src/error.ts +++ b/packages/jobs/jobs/src/error.ts @@ -10,6 +10,7 @@ import { HarnessError } from '@deepseek-ai/dsh-error' * - `JOB_NOT_FOUND` — the job id names no listed job. * - `JOB_FOREIGN_SESSION` — the job belongs to another Session. * - `JOB_OWNER_NOT_LIVE` — the owning Agent is no longer live. + * - `JOB_OWNER_REGISTRY_UNAVAILABLE` — a job names an owner, but the composition has no Agent registry to resolve it. * - `JOB_LIMIT_REACHED` — the registry's configured job limit is reached. */ export const JOB_ERROR_CODES = [ @@ -18,6 +19,7 @@ export const JOB_ERROR_CODES = [ 'JOB_NOT_FOUND', 'JOB_FOREIGN_SESSION', 'JOB_OWNER_NOT_LIVE', + 'JOB_OWNER_REGISTRY_UNAVAILABLE', 'JOB_LIMIT_REACHED', ] as const diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 341f928b4c..59409205d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13140,9 +13140,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout - '@deepseek-ai/dsh-tools': - specifier: workspace:* - version: link:../../core/tools packages/jobs/tool-jobs: dependencies: From 02c62579875ebc878cec487467074b78126e7949 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:43:12 +0800 Subject: [PATCH 31/81] fix(shell-env): keep the dsh-llm devDependency its tests import Moving ShellEnvError onto dsh-error replaced the existing dsh-llm devDependency, but the shell-env tests still import ToolCallId from dsh-llm. Both are now declared. Refs #7869 --- packages/shell/shell-env/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 3393956475..cb1beb7098 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", "@deepseek-ai/dsh-error": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-shell": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59409205d1..26fc83966c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15528,6 +15528,9 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:* version: link:../../util/home-paths + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm '@deepseek-ai/dsh-shell': specifier: workspace:* version: link:../shell From 25b2fd24c0d32578a36e3149c7b389d1d2e6a375 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 17:43:13 +0800 Subject: [PATCH 32/81] docs(sandbox-policy): state that a relative session cwd also fails The workspaceRoot row named only a NUL byte, while the session cwd check rejects relative paths too, as the resolve() JSDoc states. Refs #7869 --- packages/sandbox/sandbox-policy/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 9bef469d7d..1d00a1307f 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6704447e52ec295b zh: 9ed812d9e29d5533 /deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration: - en: 0b62eae224181946 - zh: 2e77e7f49bd074d4 + en: 4425f5a22850d740 + zh: 7dfd299ade0fc561 /deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory: en: 841ea47ef1ed7ee6 zh: cd573e67a67742e2 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 0d26190122..96db8d41e9 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -45,7 +45,7 @@ Load the package with a default mode; the fail-safe default is `read-only`, and | Field | Default | Meaning | |---|---|---| | `mode` | `read-only` | The deployment default mode a session starts from, validated at load | -| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd; a cwd containing a NUL byte fails the call with `SANDBOX_INVALID_ROOT` | +| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd; a relative cwd or one containing a NUL byte fails the call with `SANDBOX_INVALID_ROOT` | | `additionalWritableRoots` | `[]` | Physical absolute directories also writable under `workspace-write`; Windows file tools honor them, Windows subprocesses ignore them, and `read-only` grants no access | For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, non-directories, and symlinked directories on each call, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index a436f298d5..372ed91b4f 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `mode` | `read-only` | 会话起始的部署默认模式,加载时验证 | -| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd;cwd 含 NUL 字节时调用以 `SANDBOX_INVALID_ROOT` 失败 | +| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd;cwd 为相对路径或含 NUL 字节时调用以 `SANDBOX_INVALID_ROOT` 失败 | | `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的物理绝对目录;Windows 文件工具执行这些授权,Windows 子进程忽略它们,`read-only` 不授予访问权限 | 对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时拒绝不存在的路径、非目录和符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。 From 946cc8d29820de59b964897789e9f0b8d145b347 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 14:26:21 +0800 Subject: [PATCH 33/81] feat(cua): ask before moving the system pointer for a session --- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../cua-hover-ax-only/guide.i18n.yaml | 4 +- .../v0.2.2-rc.1/cua-hover-ax-only/guide.md | 4 +- .../v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md | 4 +- .../experimental/cua-macos/README.i18n.yaml | 8 +- packages/experimental/cua-macos/README.md | 6 +- packages/experimental/cua-macos/README.zh.md | 6 +- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 2 +- .../cua-macos/native/README.zh.md | 2 +- .../native/Sources/CuaNative/AXHover.swift | 7 -- .../native/Sources/CuaNative/AXWorker.swift | 29 ++++---- .../CuaNative/NativeProtocol.generated.swift | 2 +- .../CuaNative/RequestCoordinator.swift | 3 +- .../native/Sources/CuaNative/Runtime.swift | 7 +- .../cua-macos/native/generate-protocol.ts | 2 +- .../cua-macos/native/protocol.schema.json | 4 +- .../cua-macos/native/tests/e2e.py | 4 +- .../cua-macos/native/tests/electron_e2e.py | 6 +- .../tests/swift/SharedHelperTests.swift | 10 +-- .../native/tests/swift/SystemHoverTests.swift | 14 ---- .../experimental/cua-macos/src/guidance.ts | 2 +- packages/experimental/cua-macos/src/index.ts | 9 +-- .../cua-macos/src/shared-native.ts | 10 +-- .../cua-macos/tests/guidance.spec.ts | 10 +-- .../tests/protocol-generation.spec.ts | 2 +- .../cua-macos/tests/provider-channel.spec.ts | 6 +- .../tests/provider-lifecycle.spec.ts | 73 ++++++++++++++++++- .../cua-macos/tests/shared-native.spec.ts | 10 +-- packages/experimental/cua/README.i18n.yaml | 4 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/README.zh.md | 2 +- packages/experimental/cua/src/api.ts | 5 +- .../cua/src/client/ApplicationApproval.tsx | 10 +-- packages/experimental/cua/src/guidance.ts | 1 + packages/experimental/cua/src/index.ts | 9 ++- packages/experimental/cua/src/locales.ts | 25 +++++++ .../experimental/cua/src/mouse-control.ts | 56 ++++++++++++++ .../experimental/cua/src/native-schema.ts | 4 +- packages/experimental/cua/src/protocol.ts | 9 ++- packages/experimental/cua/src/types.ts | 5 ++ packages/experimental/cua/tests/api.spec.ts | 8 ++ .../application-approval.client.spec.tsx | 14 ++++ .../session/cua-native/replay.override.json | 66 +++++++++++++++++ .../cua-native/workspace/native-helper.mjs | 14 ++-- 46 files changed, 346 insertions(+), 146 deletions(-) create mode 100644 packages/experimental/cua/src/mouse-control.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dede6981f9..ed075c348a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1144,15 +1144,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:32`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Allow mouseMove to move the visible system pointer when AX hover is unavailable. Off by default to avoid interrupting the user. */ - systemPointerHover: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9c142b9b43..f01692f549 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1146,15 +1146,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:32`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Allow mouseMove to move the visible system pointer when AX hover is unavailable. Off by default to avoid interrupting the user. */ - systemPointerHover: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml index 9a48f7428c..cbce06f055 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 55c3070b24b4ad39 zh: 2555d127b0f4e4a0 /macos-hover-keeps-the-system-pointer-in-place-by-default/migration: - en: 1f0ab7e8741e821f - zh: f59adc3a23ebcb43 + en: bf3f886ad344f12a + zh: c877a616d4e147fb diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md index f7191cc254..a7d03dc869 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md @@ -14,5 +14,5 @@ On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that adver ## Migration 1. For an available control, use its advertised action or click it directly. Use browser tools for web content that requires page hover. -2. To restore visible pointer movement, set `systemPointerHover: true` in the `config` of the macOS provider entry (`id: cua`) in your profile's `cordis.patch.yml`. Preserve the entry's other settings. If you set `helperPath`, rebuild the custom helper so it accepts `systemPointerHover` in the connection handshake. Restart the profile. -3. Call `win.mouseMove` on a foreground, unobscured control without AX alternate UI. The opt-in result reports `hoverRoute: "system-pointer"` and `systemPointerMoved: true`. Keep the default to avoid disturbing the user's cursor. +2. Remove `systemPointerHover` from the macOS provider entry in your profile’s `cordis.patch.yml`. If you set `helperPath`, rebuild that helper to support the per-request `useSystemPointer` field. Restart the profile. +3. When the task requires a physical hover, call `cua.requestMouseControl()`. After the user approves, `win.mouseMove` on an unobscured foreground target returns `hoverRoute: "system-pointer"` and `systemPointerMoved: true`. This grant expires when DSH restarts. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md index 713ae43fb3..17064261b3 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md @@ -14,5 +14,5 @@ description: "macOS 的 mouseMove 默认要求控件支持 AX 备用界面,否 ## 迁移 1. 对已经可用的控件,可执行它声明的动作或直接点击。需要网页悬停时可用浏览器工具。 -2. 要恢复可见光标移动,在 profile 的 `cordis.patch.yml` 中,给 macOS 提供方条目(`id: cua`)的 `config` 添加 `systemPointerHover: true`。保留该条目的其他设置。如果指定了 `helperPath`,先重新构建自定义 helper,使其在建立连接时能读取 `systemPointerHover`,然后重启 profile。 -3. 对前台未受遮挡且不提供 AX 备用界面的控件调用 `win.mouseMove`。启用配置时,结果为 `hoverRoute: "system-pointer"` 和 `systemPointerMoved: true`。要保留用户的光标位置,使用默认配置。 +2. 从 profile 的 `cordis.patch.yml` 中移除 macOS 提供方配置项 `systemPointerHover`。如果设置了 `helperPath`,请重新构建该 helper,使其支持按请求读取 `useSystemPointer` 字段,然后重启 profile。 +3. 任务确实需要移动鼠标时,调用 `cua.requestMouseControl()`。用户同意后,对前台未受遮挡的目标调用 `win.mouseMove`,结果为 `hoverRoute: "system-pointer"` 和 `systemPointerMoved: true`。重启 DSH 后授权失效。 diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 0d83c6e538..25ab23c779 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,11 +15,11 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: 533ed4dd11af5126 - zh: 03036e8c949d6aca + en: 7fdb7c0462275417 + zh: 0a4a9562f4a82945 /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: - en: b734a0907747580e - zh: 8110c5951b70e100 + en: 2391a460e295cfca + zh: 1889a903efb6142d /deepseek-ai-dsh-experimental-cua-macos/understand-the-implementation: en: 4e82b85a5aa72eee zh: 4e004b7a205d8688 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 46b035112e..3190f8561d 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -64,7 +64,6 @@ Source builds default to `sourceSigning: ad-hoc`, without creating certificates | `buildTimeoutMs` | `300000` | Maximum native preparation time. | | `timeoutMs` | `30000` | Per-operation deadline; cancellation can leave partial input. | | `connectTimeoutMs` | `5000` | Deadline for connecting to or starting the shared helper. | -| `systemPointerHover` | `false` | Allow `mouseMove` to move the visible cursor when AX hover is unavailable. | | `permissionPollIntervalMs` | `1000` | Interval in milliseconds between permission and position checks while the native guide is open. | | `screenshotTimeoutMs` | `5000` | Screenshot deadline including queue time, capped by `timeoutMs`. | | `axTimeoutMs` | `3000` | Deadline per Accessibility system call, configured independently of the complete operation. | @@ -115,7 +114,7 @@ Use the `cua` API to discover and select the application (`listApps`, `getState` `getWindow` reads its initial tree silently. An owned window without an AX surface returns an empty snapshot and remains available for screenshots and coordinate input. Within one API instance, the facade keeps one current accessibility snapshot per application handle, shared by every app and window object for that handle, including repeated selections. Reading any window replaces that snapshot; element operations use the latest snapshot ID, so use IDs from the latest tree and do not assume an older ID still identifies the same control. A failed read publishes no partial snapshot; read again without repeating the preceding input. Window objects own screenshots and pointer input. Keyboard methods stay on `app` because input follows the app’s current focus. -`mouseMove(target)` defaults to AX hover. An enabled target or ancestor must advertise both `AXShowAlternateUI` and `AXShowDefaultUI`. Moving into its descendants keeps the UI open, and leaving restores it. Without an AX route the helper returns `HOVER_UNAVAILABLE` before sending input. Click a visible control or use `performAction` with an advertised action. For browser content without these AX actions, use browser tools. Enable `systemPointerHover` only when moving the user’s visible cursor is acceptable. That route requires a foreground, unobscured target and leaves the cursor at the destination. +`mouseMove(target)` defaults to AX hover. An enabled target or ancestor must advertise both `AXShowAlternateUI` and `AXShowDefaultUI`. Moving into its descendants keeps the UI open, and leaving restores it. Without an AX route the helper returns `HOVER_UNAVAILABLE` before sending input. Click a visible control or use `performAction` with an advertised action. For browser content without these AX actions, use browser tools. If these alternatives cannot complete the task, `cua.requestMouseControl()` asks for this session until DSH restarts. On approval, every subsequent hover moves the system pointer to the foreground, unobscured target and leaves it there. `paste(text, { format: "text" | "html" })` temporarily publishes the requested clipboard format and invokes the application’s enabled standard Paste menu command. It keeps the temporary format until a pasteboard reader requests its data, then restores every captured original format while it still owns the clipboard, preserving a later copy. Paste operations share a cross-process lock and cancellation joins helper cleanup. A forced process exit can prevent restoration. Unsupported Paste commands fail without typing fallback; `typeText` remains separate key-event input. @@ -186,6 +185,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -210,7 +210,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 341796b8c4..87c9fb046f 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -64,7 +64,6 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. | `buildTimeoutMs` | `300000` | 原生组件准备的最长时间。 | | `timeoutMs` | `30000` | 单次操作截止时间,取消后可能已发生部分输入。 | | `connectTimeoutMs` | `5000` | 连接或启动共享 helper 的截止时间。 | -| `systemPointerHover` | `false` | AX 悬停不可用时,允许 `mouseMove` 移动可见光标。 | | `permissionPollIntervalMs` | `1000` | 原生引导打开期间检查权限和窗口位置的间隔,单位为毫秒。 | | `screenshotTimeoutMs` | `5000` | 截图总期限,包含排队时间,且不超过 `timeoutMs`。 | | `axTimeoutMs` | `3000` | 每次 Accessibility 系统调用的截止时间,与完整操作截止独立配置。 | @@ -115,7 +114,7 @@ void await window.getScreenshot() `getWindow` 静默读取初始树。已确认归属但没有 AX 界面的窗口返回空快照,仍可截图和发送坐标输入。在单个 API 实例内,门面按应用句柄保存一份当前辅助功能快照,该句柄的所有应用对象和窗口对象共享它,包括重复选择。读取任一窗口都会替换该快照;元素操作使用最新快照 ID,因此应使用最新树中的元素 ID,不应假定旧 ID 仍指向同一控件。读取失败不会发布部分快照;可以重新读取,但不要重复此前的输入。窗口对象负责截图和指针输入。键盘方法保留在 `app` 上,因为输入跟随应用的当前焦点。 -`mouseMove(target)` 默认通过 AX 悬停。目标或其祖先必须已启用,并同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`。进入子控件会保持展开,离开后恢复默认界面。如果没有可用的 AX 路径,helper 返回 `HOVER_UNAVAILABLE`,不发送输入。可点击已经可见的控件,或用 `performAction` 执行控件声明的动作。浏览器内容缺少这些 AX 动作时,请使用浏览器工具。只有允许移动用户可见光标时才开启 `systemPointerHover`。这条路径要求目标在前台且未被遮挡,操作后光标停在目标位置。 +`mouseMove(target)` 默认通过 AX 悬停。目标或其祖先必须已启用,并同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`。进入子控件会保持展开,离开后恢复默认界面。如果没有可用的 AX 路径,helper 返回 `HOVER_UNAVAILABLE`,不发送输入。可点击已经可见的控件,或用 `performAction` 执行控件声明的动作。浏览器内容缺少这些 AX 动作时,请使用浏览器工具。只有其他操作都无法完成任务时,才调用 `cua.requestMouseControl()` 询问用户。用户同意后,本会话后续悬停会把系统光标移到前台未受遮挡的目标并停在那里,重启 DSH 后授权失效。 `paste(text, { format: "text" | "html" })` 临时提供指定格式的剪贴板内容,并调用应用已启用的标准 Paste 菜单命令。临时格式保留到剪贴板读取方请求其数据,然后只在仍持有剪贴板时恢复保存的全部原格式,保留期间产生的新复制内容。粘贴操作共用跨进程锁,取消会等待辅助进程清理。强制终止进程可能阻止恢复。应用不支持 Paste 命令时明确失败,不自动改成逐字输入;`typeText` 仍单独提供按键事件输入。 @@ -186,6 +185,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -210,7 +210,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 8d43fbd6b8..b858a45026 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,8 +21,8 @@ en: 88a59904ca4ebedc zh: 14f2b666d7382145 /dsh-cua-native-helper/observation-and-input: - en: 2d380e942a4057e4 - zh: fe7c9339ec63a6e5 + en: c7b28877014797fa + zh: 069271b6be75c8c1 /dsh-cua-native-helper/verification: en: 49f4cce2a4cc3114 zh: e9d65c92ef35fe24 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index bc80110951..107904886c 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -121,7 +121,7 @@ Tree observation and action discovery treat a generic AX failure reading one ele `paste` publishes one plain-text or HTML clipboard format, dispatches the application's enabled standard Paste menu command, and waits for a clipboard data request. It then restores the preserved formats only while its ownership marker and change count still match. The Host serializes its pastes across helpers with a file lock. maxClipboardBytes bounds both input and preserved contents; unsupported menus and oversized data fail before dispatch. A later copy is preserved. Cancellation restores owned clipboard data when possible; forced termination cannot guarantee restoration, and a data request does not prove the target completed editing. After dispatch, `pasteReadTimeoutMs` bounds waiting for clipboard consumption. Expiry returns `PASTE_NOT_CONSUMED`, closes the promised data provider, and restores the original clipboard only while still owned. The failure does not prove the app will never process its queued Paste command; inspect the target before deciding whether to retry. -`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. The macOS provider sets `systemPointerHover` in the connection handshake. Diagnostic clients that omit this setting may opt in per request with `allowSystemPointer: true`. The default is false. Only true permits posting a system pointer move after checking the foreground process and window hit. +`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. After session approval, the Host sends `useSystemPointer: true` on each `mouseMove` to select the system pointer directly. The opt-in system pointer route returns `FOREGROUND_REQUIRED` if the target is obscured or belongs to a background app. The result reports the route and whether the visible cursor moved in `hoverRoute` and `systemPointerMoved`. It never presses a button or restores the cursor after a read. Later input may interrupt the hover. Clicks send their button events at the target without an off-window priming click. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 0927a82bce..1834f216c6 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -121,7 +121,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 `paste` 提供一种纯文本或 HTML 剪贴板格式,调用应用已启用的标准 Paste 菜单命令,并等待剪贴板数据请求。随后仅在所有权标记和修改计数仍匹配时恢复原有格式。Host 通过文件锁串行化不同 helper 的粘贴。maxClipboardBytes 同时限制输入和保留内容,不支持的菜单和过大的数据在分派前失败。后续复制会被保留。取消会尽可能恢复仍持有的剪贴板数据,强制结束不保证恢复,数据请求也不证明目标已完成编辑。 分派完成后,`pasteReadTimeoutMs` 限制等待剪贴板读取的时间。到期返回 `PASTE_NOT_CONSUMED`,关闭延迟数据提供方,并仅在仍持有剪贴板时恢复原内容。该失败不能证明应用以后不会处理已排队的 Paste 命令;决定是否重试前先检查目标内容。 -默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。macOS 提供方在连接握手中设置 `systemPointerHover`。未在握手中指定该配置的诊断客户端,可以在单次请求中传入 `allowSystemPointer: true`。默认值为 false。只有值为 true 才允许移动全局光标,且须检查前台进程和鼠标命中的窗口。 +默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。用户授权后,Host 在本会话的每次 `mouseMove` 请求中传入 `useSystemPointer: true`,直接使用系统光标。 开启后,系统指针路线会检查前台应用和坐标命中的窗口,不满足条件时返回 `FOREGROUND_REQUIRED`。返回值 `hoverRoute` 和 `systemPointerMoved` 说明走了哪条路线以及是否移动了可见光标。悬停不会按下鼠标,也不会在读取后还原光标。后续输入可能中断悬停。点击只在目标点发送按键事件,不先在窗口外点击。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXHover.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXHover.swift index 17b142d113..959034ff4f 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXHover.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXHover.swift @@ -2,13 +2,6 @@ import ApplicationServices final class AXHover { - /// Only diagnostic clients without a connection policy may opt in per request. - static func requireSystemPointer(connectionSetting: Bool?, requestSetting: Bool?) throws { - guard connectionSetting ?? requestSetting ?? false else { - throw NativeFailure(code: "HOVER_UNAVAILABLE", message: "No enabled element at this target or in its ancestors offers both AXShowAlternateUI and AXShowDefaultUI. System-pointer hover is disabled; this provider has no other pointer-free hover route. No input was sent and the pointer did not move. Use click on an available control or performAction with an action from listActions.") - } - } - private var revealed: (element: AXUIElement, windowID: UInt32)? /// Entering revealed descendants preserves their parent UI; leaving restores it once. diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift index c4cc6066c8..c44b4078af 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift @@ -78,7 +78,6 @@ actor AXWorker { private let axTimeout: Float private let textEventInterval: Double private let maxClickHoldMs: Int - private let systemPointerHover: Bool? private let sessionState: @Sendable () -> Bool? private var access: AXAccess? private var keyboard: KeyboardInput? @@ -86,13 +85,12 @@ actor AXWorker { private var hovers: [String: AXHover] = [:] private var closed = false - init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double, maxClickHoldMs: Int, systemPointerHover: Bool? = nil, + init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double, maxClickHoldMs: Int, sessionState: @escaping @Sendable () -> Bool? = NativePlatform.sessionLocked) { self.cancellation = cancellation self.axTimeout = axTimeout self.textEventInterval = textEventInterval self.maxClickHoldMs = maxClickHoldMs - self.systemPointerHover = systemPointerHover self.sessionState = sessionState } private func identifier(_ kind: String) throws -> String { @@ -106,13 +104,13 @@ actor AXWorker { access = created return created } - private func moveUsingAX(_ element: AXUIElement?, windowID: UInt32, handle: String, params: RequestParameters) throws -> Bool { - let requestSetting = try params.optionalBoolean("allowSystemPointer") + private func moveUsingAX(_ element: AXUIElement?, windowID: UInt32, handle: String) throws -> DriverOutcome { let hover = hovers[handle] ?? AXHover() hovers[handle] = hover - if try hover.move(to: element, in: windowID, access: ax()) { return true } - try AXHover.requireSystemPointer(connectionSetting: systemPointerHover, requestSetting: requestSetting) - return false + guard try hover.move(to: element, in: windowID, access: ax()) else { + throw NativeFailure(code: "HOVER_UNAVAILABLE", message: "No enabled element at this target or in its ancestors offers both AXShowAlternateUI and AXShowDefaultUI. No input was sent and the pointer did not move. Use click on an available control or performAction with an action from listActions.") + } + return .result(Self.mouseMoveResult(route: "accessibility", pointerMoved: false)) } private func inputs() -> (KeyboardInput, WindowPointerInput) { if let keyboard, let pointer { return (keyboard, pointer) } @@ -351,6 +349,7 @@ actor AXWorker { try ax().selectText(element(handle, params), text: params.string("text"), prefix: params.optionalString("prefix"), suffix: params.optionalString("suffix"), mode: params.optionalString("selectionType") ?? "text") case "click", "mouseMove": + let useSystemPointer = try params.optionalBoolean("useSystemPointer") ?? false let holdMs = params.contains("holdMs") ? try params.integer("holdMs", in: 0...maxClickHoldMs) : nil let button = try params.optionalString("mouseButton") ?? "left" let count = params.contains("clickCount") ? try params.integer("clickCount", in: 1...2) : 1 @@ -370,9 +369,7 @@ actor AXWorker { } if method == "mouseMove" { _ = try NativePlatform.ownedWindow(identity.pid, enclosing.id) - if try moveUsingAX(selected, windowID: enclosing.id, handle: handle, params: params) { - return .result(Self.mouseMoveResult(route: "accessibility", pointerMoved: false)) - } + if !useSystemPointer { return try moveUsingAX(selected, windowID: enclosing.id, handle: handle) } } else { try ax().requireEnabled(selected) } @@ -389,12 +386,14 @@ actor AXWorker { let route = WindowRoute(owner: identity, windowID: window.windowID, bounds: window.bounds) if method == "mouseMove" { try WindowPointerInput.validate(point, in: route) - if elementWindow == nil { + if !useSystemPointer { var hit = try ax().element(at: CGPoint(x: window.bounds.x + point.x, y: window.bounds.y + point.y), owner: identity) if let target = hit, try ax().windowID(target) != window.windowID { hit = nil } - if try moveUsingAX(hit, windowID: window.windowID, handle: handle, params: params) { - return .result(Self.mouseMoveResult(route: "accessibility", pointerMoved: false)) - } + return try moveUsingAX(hit, windowID: window.windowID, handle: handle) + } + if let hover = hovers[handle] { + _ = try hover.move(to: nil, in: window.windowID, access: ax()) + hovers.removeValue(forKey: handle) } return .mouseMove(route, point) } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift index 0da4e5cb96..a39965362b 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift @@ -14,7 +14,7 @@ enum NativeProtocol { "listActions": ["appHandle", "elementId", "snapshotId", "windowId"], "listApps": [], "listWindows": ["appHandle"], - "mouseMove": ["allowSystemPointer", "appHandle", "elementId", "snapshotId", "windowId", "x", "y"], + "mouseMove": ["appHandle", "elementId", "snapshotId", "useSystemPointer", "windowId", "x", "y"], "openPermissionSettings": ["permission", "settingsMessage"], "paste": ["appHandle", "format", "maxClipboardBytes", "maxDepth", "maxElements", "pasteReadTimeoutMs", "text", "windowId"], "performAction": ["action", "appHandle", "argument", "elementId", "snapshotId", "windowId"], diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift index 000a69e848..3a778cb8ae 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift @@ -42,8 +42,7 @@ import Darwin self.onClose = onClose self.onFatal = onFatal worker = AXWorker(cancellation: cancellation, axTimeout: configuration.axTimeoutSeconds, - textEventInterval: configuration.textEventIntervalSeconds, maxClickHoldMs: configuration.maxClickHoldMs, - systemPointerHover: configuration.systemPointerHover) + textEventInterval: configuration.textEventIntervalSeconds, maxClickHoldMs: configuration.maxClickHoldMs) input = JSONLInput(limit: configuration.maxRequestBytes, cancellation: cancellation, channel: channel) self.writer = writer } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift index c31a07ebef..d61f0803ab 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift @@ -21,7 +21,6 @@ struct HelperConfiguration: Sendable { var shutdownGraceSeconds: Double = 3 var textEventIntervalSeconds: Double = 0.00025 var maxClickHoldMs = 5000 - var systemPointerHover: Bool? var timeoutSeconds: Double = 30 var screenshotTimeoutSeconds: Double = 5 init(arguments: [String]) throws { @@ -65,7 +64,7 @@ struct HelperConfiguration: Sendable { guard let fields = decoded.object, Set(fields.keys) == ["type", "protocol", "runtimeKey", "config"], fields["type"] == .string("hello"), fields["protocol"] == .number(1), fields["runtimeKey"] == .string(runtimeKey), let config = fields["config"]?.object, - Set(config.keys).subtracting(["systemPointerHover"]) == ["maxRequestBytes", "maxResponseBytes", "axTimeoutMs", "textEventIntervalMs", "maxClickHoldMs", "screenshotTimeoutMs", "timeoutMs", "graceMs"] else { + Set(config.keys) == ["maxRequestBytes", "maxResponseBytes", "axTimeoutMs", "textEventIntervalMs", "maxClickHoldMs", "screenshotTimeoutMs", "timeoutMs", "graceMs"] else { throw NativeFailure(code: "NATIVE_PROTOCOL", message: "Expected shared helper protocol 1 hello for this runtime") } func number(_ name: String) throws -> String { @@ -83,10 +82,6 @@ struct HelperConfiguration: Sendable { "--screenshot-timeout-ms", number("screenshotTimeoutMs"), "--timeout-ms", number("timeoutMs"), "--shutdown-grace-ms", number("graceMs"), ]) - if let setting = config["systemPointerHover"] { - guard case .bool(let allowed) = setting else { throw RequestParameters.invalid("systemPointerHover must be a boolean") } - systemPointerHover = allowed - } } } diff --git a/packages/experimental/cua-macos/native/generate-protocol.ts b/packages/experimental/cua-macos/native/generate-protocol.ts index 398999cc2c..c643dfb830 100644 --- a/packages/experimental/cua-macos/native/generate-protocol.ts +++ b/packages/experimental/cua-macos/native/generate-protocol.ts @@ -10,7 +10,7 @@ import { PermissionGuideParameters, PermissionSettingsParameters } from '../src/ */ export function renderNativeProtocol(): { json: string; swift: string } { const { fields, enums, json } = nativeProtocol({ - mouseMove: z.object({ allowSystemPointer: z.boolean().optional() }), + mouseMove: z.object({ useSystemPointer: z.boolean().optional() }), requestPermission: PermissionGuideParameters, openPermissionSettings: PermissionSettingsParameters, }) const quoted = (values: Iterable) => [...values].sort().map(value => JSON.stringify(value)).join(', ') diff --git a/packages/experimental/cua-macos/native/protocol.schema.json b/packages/experimental/cua-macos/native/protocol.schema.json index b0d74a84dc..818394ad1b 100644 --- a/packages/experimental/cua-macos/native/protocol.schema.json +++ b/packages/experimental/cua-macos/native/protocol.schema.json @@ -489,7 +489,7 @@ "minLength": 1, "description": "Opaque string defined by the platform provider, valid for at least the lifetime of one snapshot." }, - "allowSystemPointer": { + "useSystemPointer": { "type": "boolean" } }, @@ -524,7 +524,7 @@ "type": "number", "minimum": 0 }, - "allowSystemPointer": { + "useSystemPointer": { "type": "boolean" } }, diff --git a/packages/experimental/cua-macos/native/tests/e2e.py b/packages/experimental/cua-macos/native/tests/e2e.py index 2a16b54b49..c5b385189a 100644 --- a/packages/experimental/cua-macos/native/tests/e2e.py +++ b/packages/experimental/cua-macos/native/tests/e2e.py @@ -236,7 +236,7 @@ def main(): assert (after["cursorChanges"], after["hoverVisible"]) == (before["cursorChanges"], before["hoverVisible"]), after def mouse_move(target): - result = call("mouseMove", target if args.alternate_hover else {**target, "allowSystemPointer": True}) + result = call("mouseMove", target if args.alternate_hover else {**target, "useSystemPointer": True}) if not args.alternate_hover: assert result["hoverRoute"] == "system-pointer" and result["systemPointerMoved"] is True, result if args.alternate_hover: @@ -271,7 +271,7 @@ def main(): mouse_move(target) wait_state(lambda actual: actual["hoverVisible"], "Input-transparent overlay blocked hover") before = cycle_overlay(2) - call("mouseMove", {**target, "allowSystemPointer": True}, "FOREGROUND_REQUIRED") + call("mouseMove", {**target, "useSystemPointer": True}, "FOREGROUND_REQUIRED") after = wait_state(lambda actual: actual["cursorSamples"] >= before["cursorSamples"] + 2, "Pointer observation did not advance") assert after["cursorChanges"] == before["cursorChanges"], "Rejected hover moved the system pointer" diff --git a/packages/experimental/cua-macos/native/tests/electron_e2e.py b/packages/experimental/cua-macos/native/tests/electron_e2e.py index 403bb21eba..d8acffbe9d 100644 --- a/packages/experimental/cua-macos/native/tests/electron_e2e.py +++ b/packages/experimental/cua-macos/native/tests/electron_e2e.py @@ -136,7 +136,7 @@ def main(): call('mouseMove', {**owner, 'snapshotId': state['snapshotId'], 'elementId': trigger['elementId'], 'x': 0}, 'INVALID_ARGUMENT') hover_target = {**owner, 'snapshotId': state['snapshotId'], 'elementId': trigger['elementId']} call('mouseMove', hover_target, 'HOVER_UNAVAILABLE') - call('mouseMove', {**hover_target, 'allowSystemPointer': True}) + call('mouseMove', {**hover_target, 'useSystemPointer': True}) actual = wait_state(lambda actual: actual['popupOpen'], 'Hover did not reveal delayed information') assert actual['pointerDowns'] == baseline and actual['lastMove'] == {'buttons': 0, 'trusted': True}, actual state = observe() @@ -151,13 +151,13 @@ def main(): target = {**owner, 'windowId': window_id, 'x': bounds[0] + bounds[2] / 2 - window['bounds']['x'], 'y': bounds[1] + bounds[3] / 2 - window['bounds']['y']} - call('mouseMove', {**target, 'allowSystemPointer': True}) + call('mouseMove', {**target, 'useSystemPointer': True}) state = observe() assert 'Hover information' in state['tree'], 'Moving into the popup dismissed it' call('click', target) actual = wait_state(lambda actual: actual['popupClicks'] == expected_clicks, 'Popup action did not receive its click') assert actual['popupOpen'] and actual['pointerDowns'] == baseline + 1 and actual['popupClickTrusted'], actual - call('mouseMove', {**owner, 'windowId': window_id, 'x': 10, 'y': 40, 'allowSystemPointer': True}) + call('mouseMove', {**owner, 'windowId': window_id, 'x': 10, 'y': 40, 'useSystemPointer': True}) wait_state(lambda actual: not actual['popupOpen'], 'Leaving the hover region did not close it') hover_sequence() helper.eof() diff --git a/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift b/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift index 52f7f0f224..c0cd744ec4 100644 --- a/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift @@ -33,14 +33,6 @@ private func hello(overrides: [String: JSONValue] = [:]) throws -> Data { } } -@Test func readsSystemPointerHoverFromEachConnection() throws { - #expect(try HelperConfiguration(hello: hello(), runtimeKey: runtimeKey).systemPointerHover == nil) - for allowed in [true, false] { - let configuration = try HelperConfiguration(hello: hello(overrides: ["systemPointerHover": .bool(allowed)]), runtimeKey: runtimeKey) - #expect(configuration.systemPointerHover == allowed) - } -} - @Test func resolvesClickHoldLimitFromHelperArguments() throws { #expect(try HelperConfiguration(arguments: []).maxClickHoldMs == 5000) for limit in [0, 7000] { @@ -56,7 +48,7 @@ private func hello(overrides: [String: JSONValue] = [:]) throws -> Data { ("axTimeoutMs", .number(0)), ("textEventIntervalMs", .number(-1)), ("screenshotTimeoutMs", .number(0)), ("timeoutMs", .string("8000")), ("graceMs", .number(-1)), ("maxClickHoldMs", .number(-1)), ("maxClickHoldMs", .number(0.5)), - ("maxClickHoldMs", .number(2147483648)), ("unexpected", .bool(true)), ("systemPointerHover", .string("true")), + ("maxClickHoldMs", .number(2147483648)), ("unexpected", .bool(true)), ("systemPointerHover", .bool(true)), ]) func refusesInvalidSharedConfiguration(_ name: String, _ value: JSONValue) throws { #expect(throws: NativeFailure.self) { try HelperConfiguration(hello: hello(overrides: [name: value]), runtimeKey: runtimeKey) } diff --git a/packages/experimental/cua-macos/native/tests/swift/SystemHoverTests.swift b/packages/experimental/cua-macos/native/tests/swift/SystemHoverTests.swift index df48871c4c..38f82ac4a8 100644 --- a/packages/experimental/cua-macos/native/tests/swift/SystemHoverTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/SystemHoverTests.swift @@ -3,20 +3,6 @@ import Testing import CoreGraphics @testable import CuaNative -@Test func unavailableAXHoverHonorsConnectionPolicyBeforeRequestFlags() throws { - for (connection, request): (Bool?, Bool?) in [(nil, nil), (nil, false), (false, nil), (false, true)] { - do { - try AXHover.requireSystemPointer(connectionSetting: connection, requestSetting: request) - Issue.record("An unapproved request was allowed to move the pointer") - } catch let failure as NativeFailure { - #expect(failure.code == "HOVER_UNAVAILABLE") - #expect(failure.message.contains("No input was sent")) - } - } - try AXHover.requireSystemPointer(connectionSetting: true, requestSetting: nil) - try AXHover.requireSystemPointer(connectionSetting: nil, requestSetting: true) -} - @Test func systemHoverRequiresTheForegroundUnobscuredTarget() throws { let owner = ProcessIdentity(pid: 42, startedSeconds: 1, startedMicroseconds: 0) let bounds = WindowBounds(x: 100, y: 100, width: 400, height: 300) diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index f8779651a9..5a340f045f 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -5,7 +5,7 @@ import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' export const PLATFORM_GUIDANCE = 'macOS: accessibility tree operations are available at app or window level. Simulated keyboard input is available at app level. Simulated mouse input is available at window level. For pointer input on a sheet, select its window ID with app.getWindow(id). For menu items outside a window, use app.invoke(id). findApps searches /Applications, /System/Applications, and ~/Applications. Use the Computer Use permission page to grant the helper application Accessibility and Screen Recording permissions in System Settings. Native window and AX bounds use macOS points, while delivered screenshots carry their own pixel dimensions. show does not switch Spaces, and Stage Manager can present reduced window geometry; explain any required foreground step before calling app.activate() or performing an advertised AXRaise action.' const methodDetails = new Map([ - ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured.'], + ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured.'], ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) diff --git a/packages/experimental/cua-macos/src/index.ts b/packages/experimental/cua-macos/src/index.ts index 4b07e9c8b7..dff6dd6997 100644 --- a/packages/experimental/cua-macos/src/index.ts +++ b/packages/experimental/cua-macos/src/index.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' import { installCua, type CuaConfig, type CuaStartupCapabilities } from '@deepseek-ai/dsh-cua' -import type { NativeChannelFactory } from '@deepseek-ai/dsh-cua/native-client' import { CuaConfigFields } from '@deepseek-ai/dsh-cua/config' import { createPermissionParameters } from './permission-guide.ts' import { nativeHostArchitecture, prepareNativeExecutable, type SourceSigning } from './native-build.ts' @@ -16,7 +15,7 @@ const CAPABILITIES: CuaStartupCapabilities = { platform: 'macos', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', - 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', + 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'requestMouseControl', 'screenshot', 'scroll', 'selectText', 'setValue', 'show', 'typeText', ], appLevelAccessibility: true, @@ -33,8 +32,6 @@ export const inject = ['computerUse', 'tools', 'systemPrompt', 'subprocess', 'ag export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Allow mouseMove to move the visible system pointer when AX hover is unavailable. Off by default to avoid interrupting the user. */ - systemPointerHover: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. @@ -45,7 +42,6 @@ export interface Config extends CuaConfig { /** Validate shared operation bounds and macOS source signing before preparing the helper. */ export const Config = Schema.object({ ...CuaConfigFields, - systemPointerHover: Schema.boolean().default(false), permissionPollIntervalMs: Schema.number().step(1).min(100).max(2 ** 31 - 1).default(1000), sourceSigning: Schema.union([ Schema.const('ad-hoc'), @@ -64,8 +60,7 @@ export const Config = Schema.object({ * @returns Completion after native preparation and tool registration. */ export async function apply(ctx: Context, config: Config): Promise { - const createChannel: NativeChannelFactory = (owner, native) => createSharedNativeChannel(owner, native, config.systemPointerHover) - const backend = { createChannel, guidance: GUIDANCE, capabilities: CAPABILITIES, + const backend = { createChannel: createSharedNativeChannel, guidance: GUIDANCE, capabilities: CAPABILITIES, permissionParameters: createPermissionParameters(config.permissionPollIntervalMs) } if (config.helperPath !== undefined) { if (!isAbsolute(config.helperPath)) throw new Error('dsh-cua helperPath must be absolute') diff --git a/packages/experimental/cua-macos/src/shared-native.ts b/packages/experimental/cua-macos/src/shared-native.ts index 7be638a96b..e76e1f0440 100644 --- a/packages/experimental/cua-macos/src/shared-native.ts +++ b/packages/experimental/cua-macos/src/shared-native.ts @@ -56,8 +56,7 @@ class SharedNativeChannel implements NativeChannel { private cleanupTimer: ReturnType | undefined private cleanupFailure: Error | undefined - constructor(private readonly owner: { subprocess: Pick }, private readonly config: NativeClientConfig, - private readonly systemPointerHover: boolean) { + constructor(private readonly owner: { subprocess: Pick }, private readonly config: NativeClientConfig) { this.stdin = new Writable({ write: (chunk: Buffer, encoding, callback) => { void this.ready.promise.then((socket) => { @@ -216,7 +215,7 @@ class SharedNativeChannel implements NativeChannel { screenshotTimeoutMs, timeoutMs, graceMs } = this.config socket.write(JSON.stringify({ type: 'hello', protocol: 1, runtimeKey, config: { maxRequestBytes, maxResponseBytes, axTimeoutMs, textEventIntervalMs, maxClickHoldMs, - screenshotTimeoutMs, timeoutMs, graceMs, systemPointerHover: this.systemPointerHover } }) + '\n') + screenshotTimeoutMs, timeoutMs, graceMs } }) + '\n') }) const receive = (chunk: Buffer): void => { const newline = chunk.indexOf(10) @@ -288,9 +287,8 @@ class SharedNativeChannel implements NativeChannel { * Prepare or join the native process; writes wait for identity-checked readiness. * @param owner - Provider owning the short-lived native startup command. * @param config - Native runtime identity, operation limits and connection deadlines. - * @param systemPointerHover - Whether this connection may move the visible pointer when AX hover is unavailable. * @returns One independently disposable Agent connection, without operation replay. */ -export function createSharedNativeChannel(owner: { subprocess: Pick }, config: NativeClientConfig, systemPointerHover: boolean): NativeChannel { - return new SharedNativeChannel(owner, config, systemPointerHover) +export function createSharedNativeChannel(owner: { subprocess: Pick }, config: NativeClientConfig): NativeChannel { + return new SharedNativeChannel(owner, config) } diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index c4a6aeec7d..2db5c24eb6 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -4,7 +4,7 @@ import type { CuaApi, CuaApp, CuaSurface, CuaWindow } from '@deepseek-ai/dsh-cua import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' import { GUIDANCE, PLATFORM_GUIDANCE } from '../src/guidance.ts' -const apiNames: Array = ['getCapabilities', 'listApps', 'findApps', 'getState', 'getApp'] +const apiNames: Array = ['getCapabilities', 'requestMouseControl', 'listApps', 'findApps', 'getState', 'getApp'] const accessibilityNames: Array> = [ 'getAccessibilityTree', 'listActions', 'invoke', 'performAction', 'setValue', 'selectText', ] @@ -28,12 +28,12 @@ it('states the macOS accessibility, keyboard, and mouse levels', () => { expect(PLATFORM_GUIDANCE).toContain('Simulated keyboard input is available at app level') expect(PLATFORM_GUIDANCE).toContain('Simulated mouse input is available at window level') const hover = GUIDANCE.split('\n').find(line => line.startsWith('- mouseMove(')) - for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE', 'systemPointerHover']) expect(hover).toContain(term) + for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE', 'The question interrupts the user']) expect(hover).toContain(term) const click = GUIDANCE.split('\n').find(line => line.startsWith('- click(')) expect(click).toContain('await app.getWindow(windowId), then call win.click(target)') expect(click).toContain('use app.invoke(id)') for (const step of ['call app.activate() first', 'outside the window', 'screenshot pixels', 'before typing or retrying']) expect(click).toContain(step) - for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'systemPointerHover', 'mouse-down']) { + for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'The question interrupts the user', 'mouse-down']) { expect(COMMON_GUIDANCE).not.toContain(platformDetail) } }) @@ -50,8 +50,8 @@ it('enumerates every available method once in its catalog entry', () => { for (const catalog of [COMMON_GUIDANCE, GUIDANCE]) { for (const signature of signatures) expect(catalog.split('\n').filter(line => line.startsWith(`- ${signature}`))).toHaveLength(1) } - expect(signatures).toHaveLength(24) - expect(new Set(signatures.map(signature => signature.slice(signature.indexOf('.') + 1, -1))).size).toBe(23) + expect(signatures).toHaveLength(25) + expect(new Set(signatures.map(signature => signature.slice(signature.indexOf('.') + 1, -1))).size).toBe(24) expect(COMMON_GUIDANCE).toContain('app.appHandle') expect(COMMON_GUIDANCE).toContain('window.windowId') }) diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index aefc6fbee6..87afd7eed9 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -26,7 +26,7 @@ describe('native protocol generation', () => { { method: 'drag', params: { appHandle: 'app', windowId: 1, fromX: 1, fromY: 1, toX: 2, toY: 2, steps: 1 } }, { method: 'paste', params: { appHandle: 'app', text: 'text', maxClipboardBytes: 1024 } }, { method: 'paste', params: { appHandle: 'app', text: 'text', pasteReadTimeoutMs: 1000 } }, - { method: 'mouseMove', params: { appHandle: 'app', windowId: 1, x: 0, y: 0, allowSystemPointer: true } }, + { method: 'mouseMove', params: { appHandle: 'app', windowId: 1, x: 0, y: 0, useSystemPointer: true } }, { method: 'findApps', params: { query: 'Editor', maxAppMatches: 1 } }, { method: 'launchApp', params: { appHandle: 'pending' } }, { method: 'applicationIcons', params: { paths: ['/Applications/Fixture.app'] } }, diff --git a/packages/experimental/cua-macos/tests/provider-channel.spec.ts b/packages/experimental/cua-macos/tests/provider-channel.spec.ts index 02d9fc0fe6..1b9ac97c44 100644 --- a/packages/experimental/cua-macos/tests/provider-channel.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-channel.spec.ts @@ -14,10 +14,10 @@ vi.mock('@deepseek-ai/dsh-cua', async importOriginal => ({ })) vi.mock('../src/shared-native.ts', () => ({ createSharedNativeChannel: vi.fn() })) -it.each([false, true])('forwards systemPointerHover=%s and native limits to each channel', async (systemPointerHover) => { +it('forwards native limits to each channel', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) - const config = Config({ helperPath: resolve('fixture-helper'), systemPointerHover }) + const config = Config({ helperPath: resolve('fixture-helper') }) const channel: NativeChannel = { stdin: new PassThrough(), stdout: new PassThrough(), done: Promise.resolve(), close() {}, waitForClose: async () => {}, @@ -32,5 +32,5 @@ it.each([false, true])('forwards systemPointerHover=%s and native limits to each const owner = { subprocess: { spawn: vi.fn() } } const native = { ...config, helperPath: resolve('fixture-helper'), cwd: process.cwd() } expect(backend.createChannel(owner, native)).toBe(channel) - expect(createSharedNativeChannel).toHaveBeenCalledExactlyOnceWith(owner, native, systemPointerHover) + expect(createSharedNativeChannel).toHaveBeenCalledExactlyOnceWith(owner, native) }) diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 48b9eb99e3..f975862baa 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -21,7 +21,7 @@ import { CuaConfig } from '@deepseek-ai/dsh-cua' import { createConfigProjector } from '@deepseek-ai/dsh-app-boot/src/config-schema/projector.ts' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import FileSystem from '@deepseek-ai/dsh-fs-local' -import UserQuestions from '@deepseek-ai/dsh-user-questions' +import UserQuestions, { UserQuestionError } from '@deepseek-ai/dsh-user-questions' import NodeRuntime from '@deepseek-ai/dsh-ptc-runtime-node' import Sandbox from '@deepseek-ai/dsh-sandbox-local' import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' @@ -61,7 +61,7 @@ vi.mock('@deepseek-ai/dsh-cua/native-client', async importOriginal => ({ })) const config = { - permissionPollIntervalMs: 1000, systemPointerHover: false, + permissionPollIntervalMs: 1000, agentPreset: false, helperPath: resolve('fixture-helper'), sourceSigning: 'ad-hoc', buildTimeoutMs: 300_000, timeoutMs: 10_000, connectTimeoutMs: 5000, screenshotTimeoutMs: 5000, axTimeoutMs: 3000, maxRequestBytes: 1024, maxResponseBytes: 4096, maxClipboardBytes: 8192, pasteReadTimeoutMs: 1000, maxAppMatches: 20, graceMs: 10, textEventIntervalMs: 8, maxClickHoldMs: 7000, maxElements: 64, maxDepth: 8, dragDurationMs: 200, dragSteps: 10, @@ -71,7 +71,6 @@ const { helperPath: _helperPath, ...automaticConfig } = config it('defaults to ad-hoc signing and requires a keychain path for certificate signing', () => { expect(NativeProvider.Config({}).sourceSigning).toBe('ad-hoc') - expect(NativeProvider.Config({}).systemPointerHover).toBe(false) expect(NativeProvider.Config({ sourceSigning: 'ad-hoc' }).sourceSigning).toBe('ad-hoc') for (const sourceSigning of [{ identity: 'Apple Development: Example', keychain: '/keychains/custom.keychain-db' }, { identity: 'ABC123', keychain: '/keychains/custom.keychain-db' }]) { expect(NativeProvider.Config({ sourceSigning }).sourceSigning).toEqual(sourceSigning) @@ -199,7 +198,7 @@ it('merges startup capabilities into runtime native fields', async () => { expect(result.value).toEqual({ ...runtime, platform: 'macos', appLevelAccessibility: true, keyboard: ['app'], browser: false, coordinateSpace: 'screenshot-pixels', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', - 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', + 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'requestMouseControl', 'screenshot', 'scroll', 'selectText', 'setValue', 'show', 'typeText', ] }) expect(native.request).toHaveBeenCalledExactlyOnceWith('getCapabilities', {}, expect.any(AbortSignal), ToolCallId('lifecycle-1')) @@ -878,3 +877,69 @@ it('routes permission actions through the macOS provider with the Client languag permission: 'screenRecording', settingsMessage: 'Open System Settings > Privacy & Security > Screen Recording manually.', }, expect.any(AbortSignal)) }) + +it('moves only for the consenting session, without granting application access or retaining consent after reload', async () => { + const f = await setup() + await f.ctx.plugin(UserQuestions) + const ask = vi.fn((id: string) => ({ answers: [{ id, selected: ['Allow for this session'] }] })) + f.ctx.on('user-questions/request', async ({ questions }) => ask(questions[0]!.id)) + const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } + native.request.mockResolvedValueOnce({ appHandle: 'app-one', name: 'Fixture', running: true, + authorization: { id: 'a'.repeat(64), name: 'Fixture', persistable: false } }) + expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false) + const move = async (useSystemPointer: boolean) => { + expect((await f.execute('mouseMove', target)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...target, useSystemPointer }, expect.any(AbortSignal), expect.any(String)) + } + await move(false) + expect((await f.execute('mouseMove', { ...target, useSystemPointer: true })).isError).toBe(true) + for (let i = 0; i < 2; i++) expect((await f.execute('requestMouseControl')).value).toBe(true) + await move(true) + const other = await f.ctx.agents.create({ sessionId: SessionId('other-mouse-session') }) + const otherCall = (method: string, params: Record) => executeToolFixture(f.ctx, { + agent: other.agent, signal: new AbortController().signal, callId: ToolCallId(method), name: 'cua', arguments: { method, params }, + }) + expect((await otherCall('requestMouseControl', {})).value).toBe(true) + expect((await otherCall('mouseMove', target)).isError).toBe(true) + await f.provider.dispose() + await f.ctx.plugin(NativeProvider, config) + expect((await f.execute('requestMouseControl')).value).toBe(true) + expect(ask.mock.calls.map(([id]) => id)).toEqual(['cua-app-access', 'cua-mouse-control', 'cua-mouse-control', 'cua-mouse-control']) +}) + +it.each(['Deny', 'custom', 'cancel'] as const)('remembers a %s answer without moving the pointer or asking again', async (answer) => { + const f = await setup() + await f.ctx.plugin(UserQuestions) + const ask = vi.fn(() => { + if (answer === 'cancel') throw new UserQuestionError('User dismissed the request', 'ASK_CANCELLED') + return { answers: [{ id: 'cua-mouse-control', selected: [answer === 'custom' ? 'Allow for this session' : answer], + ...(answer === 'custom' ? { custom: 'maybe later' } : {}) }] } + }) + f.ctx.on('user-questions/request', async () => ask()) + expect((await f.execute('requestMouseControl')).value).toBe(false) + expect((await f.execute('requestMouseControl')).value).toBe(false) + expect(ask).toHaveBeenCalledTimes(1) + expect(native.request).not.toHaveBeenCalled() +}) + +it('drops late approval after cancellation and admits only one pending question', async () => { + const f = await setup() + expect((await f.execute('requestMouseControl')).isError).toBe(true) + await f.ctx.plugin(UserQuestions) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + f.ctx.on('user-questions/request', async () => { + entered.resolve(undefined) + await release.promise + return { answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }] } + }) + const controller = new AbortController() + const first = f.execute('requestMouseControl', {}, controller.signal) + await entered.promise + expect((await f.execute('requestMouseControl')).isError).toBe(true) + controller.abort() + release.resolve(undefined) + await Promise.allSettled([first]) + expect((await f.execute('requestMouseControl')).value).toBe(true) + expect(native.request).not.toHaveBeenCalled() +}) diff --git a/packages/experimental/cua-macos/tests/shared-native.spec.ts b/packages/experimental/cua-macos/tests/shared-native.spec.ts index 871475fb53..86c5437ccf 100644 --- a/packages/experimental/cua-macos/tests/shared-native.spec.ts +++ b/packages/experimental/cua-macos/tests/shared-native.spec.ts @@ -133,8 +133,8 @@ async function fixture(options: { timeoutMs: 30_000, graceMs: options.graceMs ?? 1000, axTimeoutMs: 3000, maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, maxClickHoldMs: 7000, } - function open(systemPointerHover = false): NativeChannel { - const channel = createSharedNativeChannel({ subprocess: { spawn } }, config, systemPointerHover) + function open(): NativeChannel { + const channel = createSharedNativeChannel({ subprocess: { spawn } }, config) channels.push(channel) return channel } @@ -172,7 +172,7 @@ describe.skipIf(process.platform === 'win32')('shared native Unix connection', ( it('keeps concurrent Agent scopes independent and leaves other sockets alive when one closes', async () => { const f = await fixture() const first = f.open() - const second = f.open(true) + const second = f.open() const firstReply = nextBytes(first) const secondReply = nextBytes(second) await Promise.all([write(first, 'one\n'), write(second, 'two\n')]) @@ -191,9 +191,7 @@ describe.skipIf(process.platform === 'win32')('shared native Unix connection', ( maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, maxClickHoldMs: 7000, } expect(f.hellos).toHaveLength(2) - for (const systemPointerHover of [true, false]) { - expect(f.hellos).toContainEqual(expect.objectContaining({ type: 'hello', protocol: 1, config: { ...limits, systemPointerHover } })) - } + for (const hello of f.hellos) expect(hello).toMatchObject({ type: 'hello', protocol: 1, config: limits }) }) it('waits for both launcher outcome and managed range release before sending any bytes', async () => { diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index f4abb13c43..b72fd91584 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -15,8 +15,8 @@ en: 5db4d4fea25493b9 zh: 65ab64fc24eb0d09 /deepseek-ai-dsh-cua/use-this-package: - en: ba1f8e6ae51f69d7 - zh: 8f4fde9857bae319 + en: a55ba012aedca751 + zh: fcddea780c0ec58e /deepseek-ai-dsh-cua/use-this-package/permission-controls: en: d92d5948a7941e8d zh: d43491ae6521b87e diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index c95e4ae8ec..39a3e522d8 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -41,7 +41,7 @@ Observation methods emit and return their result. Use `void await app.getAccessi Application consent precedes launch, observation, capture, and input. Session access is stored by conversation and application identity and survives DSH restart and Computer Use disablement. Other conversations require their own consent. Saved grants require a platform-provided reusable application identity. Refusal and revocation close affected Agent connections, so their old objects require fresh selection. No input is replayed after cancellation, timeout, or reconnect. -Actions return a `dispatched` boolean; `mouseMove` also reports `hoverRoute` and `systemPointerMoved`, and `show()` adds `windowId` and `foregroundChanged`. Read fresh state to verify the result. Screenshots and pointer actions are available only on explicit window objects. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 999 after a successful window observation. Values are divided by 1000 and scaled to the window dimensions, then clamped inside the window. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to the provider’s native window coordinates. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. +Use `cua.requestMouseControl()` only when the task requires moving the user’s pointer. Approval applies to `mouseMove` in this session until DSH restarts. Actions return a `dispatched` boolean; `mouseMove` also reports `hoverRoute` and `systemPointerMoved`, and `show()` adds `windowId` and `foregroundChanged`. Read fresh state to verify the result. Screenshots and pointer actions are available only on explicit window objects. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 999 after a successful window observation. Values are divided by 1000 and scaled to the window dimensions, then clamped inside the window. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to the provider’s native window coordinates. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. `click(target, { holdMs: 800 })` holds the mouse button for 800 milliseconds before release. `holdMs` applies only to `click`; it accepts nonnegative integers and applies to each press of a double-click; zero releases without an additional hold. The provider config `maxClickHoldMs` limits explicit holds and defaults to 5000 ms. Explicit holds use pointer input; omission preserves ordinary click behavior. The operation deadline includes the hold, and cancellation or timeout releases the button. Received input cannot be undone; forced helper termination cannot guarantee release. diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index c1c5ed863c..c2933dc0d1 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 应用授权发生在启动、观察、截图和输入之前。会话许可按对话和应用身份持久化,重启 DSH 或关闭 Computer Use 后仍可复用,其他对话需要单独授权。长期许可要求平台提供可复用的应用身份。拒绝或撤销会关闭受影响的 Agent 连接,因此旧对象必须重新选择目标。取消、超时或重新连接后均不重放输入。 -动作返回 `dispatched` 布尔值;`mouseMove` 还会返回 `hoverRoute` 和 `systemPointerMoved`,`show()` 还会返回 `windowId` 和 `foregroundChanged`。请读取新状态以验证结果。截图和指针操作只能通过显式窗口对象使用。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~999 的整数坐标。数值除以 1000 后按窗口尺寸缩放,并限制在窗口内。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为提供方的原生窗口坐标。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 +只有任务确实需要移动用户的鼠标时,才调用 `cua.requestMouseControl()` 询问用户。授权仅适用于当前会话的 `mouseMove`,重启 DSH 后失效。动作返回 `dispatched` 布尔值;`mouseMove` 还会返回 `hoverRoute` 和 `systemPointerMoved`,`show()` 还会返回 `windowId` 和 `foregroundChanged`。请读取新状态以验证结果。截图和指针操作只能通过显式窗口对象使用。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~999 的整数坐标。数值除以 1000 后按窗口尺寸缩放,并限制在窗口内。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为提供方的原生窗口坐标。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 `click(target, { holdMs: 800 })` 按住鼠标按钮 800 毫秒后松开。`holdMs` 仅适用于 `click`;它接受非负整数,双击时应用于每次按下;0 表示按下后立即松开,不额外等待。提供方配置 `maxClickHoldMs` 限制显式按住时长,默认 5000 毫秒。指定时长后使用鼠标事件;省略时保留普通点击行为。按住时间计入操作超时,取消或超时会松开按钮。已接收的输入无法撤销;辅助进程被强制终止时无法保证松开。 diff --git a/packages/experimental/cua/src/api.ts b/packages/experimental/cua/src/api.ts index 22039c6591..930db84195 100644 --- a/packages/experimental/cua/src/api.ts +++ b/packages/experimental/cua/src/api.ts @@ -1,6 +1,6 @@ /** Object facade over logged native operations; its caller owns evaluator persistence. */ import { Buffer } from 'node:buffer' -import type { z } from 'zod' +import { z } from 'zod' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { ActionNamesResult, ActionResult, AppResult, AppsResult, CapabilitiesResult, FoundAppsResult, HoverResult, Request, ScreenshotResult, ShowResult, StateResult, WindowsResult } from './protocol.ts' import type { CuaApi, CuaApp, CuaAppHandle, CuaCapabilities, CuaDispatch, CuaObservationOptions, CuaObservationSink, CuaOperation, CuaScreenshot, CuaSurface, CuaWindow } from './types.ts' @@ -51,6 +51,9 @@ export function createCuaApi(dispatch: CuaDispatch, sink?: CuaObservationSink): const objects = createCuaObjects(call, sink) const api: CuaApi = { + async requestMouseControl() { + return z.boolean().parse(await call('requestMouseControl', {})) + }, async getCapabilities(options) { const result = CapabilitiesResult.parse(await call('getCapabilities', {})) capabilities = result diff --git a/packages/experimental/cua/src/client/ApplicationApproval.tsx b/packages/experimental/cua/src/client/ApplicationApproval.tsx index a82a115fc4..f762d5d624 100644 --- a/packages/experimental/cua/src/client/ApplicationApproval.tsx +++ b/packages/experimental/cua/src/client/ApplicationApproval.tsx @@ -5,18 +5,18 @@ import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client' import type {} from '@deepseek-ai/dsh-client-ui-approval/client' /** - * Select only the provider's fixed application question; other interactions keep their renderer. + * Select the provider’s application and mouse-control questions; other interactions keep their renderer. * @param pending The session's currently elected interaction. - * @returns Its application decision, or null for another interaction. + * @returns Its Computer Use decision, or null for another interaction. */ export function selectApplicationApproval(pending: ComposerChainProps['pendingInteraction']) { if (pending?.kind !== 'question' || pending.questions.length !== 1) return null const item = pending.questions[0] as (typeof pending.questions)[number] - if (item.id !== 'cua-app-access' || item.intent?.kind !== 'approval') return null + if ((item.id !== 'cua-app-access' && item.id !== 'cua-mouse-control') || item.intent?.kind !== 'approval') return null return { pending, item, intent: item.intent } } -/** Composer bindings for an application decision and caller-owned localized copy. */ +/** Composer bindings for a Computer Use decision and caller-owned localized copy. */ export type ApplicationApprovalProps = PropsRuntime<'conversation.composer'> & PropsRenderFactories & PropsLocale<'cuaPermissions'> & { matched: NonNullable> resolveText: (text: { en: string; [locale: string]: string }) => string @@ -32,7 +32,7 @@ export function ApplicationApproval({ matched: { pending, item, intent }, resolv const answer = (label: string) => pending.answer({ answers: [{ id: item.id, selected: [label] }] }) return renderFactorySlot('conversation.approval.card', { requestKey: pending.key, - warning: { label: t('approvalRisks'), title: t('approvalRiskTitle'), detail: t('approvalRiskNotice') }, + ...(item.id === 'cua-app-access' ? { warning: { label: t('approvalRisks'), title: t('approvalRiskTitle'), detail: t('approvalRiskNotice') } } : {}), title: intent.display === undefined ? item.question : resolveText(intent.display.question), ...(item.detail === undefined ? {} : { description: intent.display?.detail === undefined ? item.detail : resolveText(intent.display.detail), diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index 745ccfe9be..5f0abd1dac 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -8,6 +8,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index d6c75f46bc..40cb0bf1cd 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -19,6 +19,7 @@ import type { CuaApplicationGrant, CuaApplicationId, CuaSystemPermission } from import type { CuaAppHandle, CuaOperation, CuaStartupCapabilities } from './types.ts' import { ApplicationIcon, AppResult, NativeScreenshotResult, Request, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts' import { CuaConfigFields } from './config.ts' +import { MouseControl } from './mouse-control.ts' import { CuaCoordinates } from './coordinates.ts' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import type {} from '@deepseek-ai/dsh-agent-preset-registry' @@ -156,6 +157,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) permissionParameters: backend.permissionParameters, store: new CuaPermissionStore(ctx, config.allowedApps.map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), config.timeoutMs), }) + const mouseControl = new MouseControl(ctx) const clients = new Map() const lifetime = new AbortController() const pending = new Map, Agent>() @@ -220,6 +222,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) ? { ...params, maxElements: config.maxElements, maxDepth: config.maxDepth } : method === 'drag' ? { ...params, durationMs: config.dragDurationMs, steps: config.dragSteps } : params + if (method === 'mouseMove') nativeParams.useSystemPointer = mouseControl.allowed(agent) const requestNative = () => client.request(method, nativeParams, signal, execution.callId) if (method === 'screenshot') { const { attachments, target } = await screenshotStore(ctx, execution) @@ -306,6 +309,10 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const signal = AbortSignal.any([execution.signal, lifetime.signal, owner.signal]) signal.throwIfAborted() const operation = (async () => { + if (request.method === 'requestMouseControl') { + if (!backend.capabilities.methods.includes(request.method)) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') + return mouseControl.request(agent, signal) + } const previous = clients.get(agent) if (previous?.disposed) { try { await previous.dispose() } catch (error) { @@ -333,7 +340,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const nativeClient = client const dispatch = async (signal: AbortSignal) => { const scoped = { ...execution, agent, signal, nativeClient, coordinates: owner.coordinates } - const result = await callNative(Request.parse(request), scoped) + const result = await callNative(request, scoped) signal.throwIfAborted() return result } diff --git a/packages/experimental/cua/src/locales.ts b/packages/experimental/cua/src/locales.ts index 499ce34339..3833690158 100644 --- a/packages/experimental/cua/src/locales.ts +++ b/packages/experimental/cua/src/locales.ts @@ -103,3 +103,28 @@ export const launchAccessQuestion = { ru: 'Разрешить запуск? Доступ к окнам запрашивается отдельно.', pt: 'Permitir iniciar esta entrada? O acesso às janelas é solicitado separadamente.', } + +/** Scope and interruption shown before the user grants mouse control. */ +export const mouseControlDisplay = { + question: { + en: 'Allow the agent to move your mouse pointer?', + zh: '允许智能体移动你的鼠标指针吗?', + ja: 'エージェントがマウスポインタを動かすことを許可しますか?', + es: '¿Permitir que el agente mueva el puntero del ratón?', + fr: 'Autoriser l’agent à déplacer le pointeur de la souris ?', + de: 'Darf der Agent den Mauszeiger bewegen?', + ru: 'Разрешить агенту перемещать указатель мыши?', + pt: 'Permitir que o agente mova o ponteiro do mouse?', + }, + detail: { + en: 'Hovering will move your pointer and leave it at the target, even while you are using the mouse. This permission applies only to this session and expires when DSH restarts.', + zh: '智能体悬停时会移动你的鼠标指针,并将它留在目标位置,即使你正在使用鼠标。授权仅限当前会话,重启 DSH 后失效。', + ja: 'ホバーすると、マウスを使用中でもポインタが移動し、移動先に残ります。許可はこのセッションに限り、DSH を再起動すると失効します。', + es: 'Al pasar sobre un elemento, el puntero se moverá y quedará allí aunque estés usando el ratón. El permiso solo se aplica a esta sesión y caduca al reiniciar DSH.', + fr: 'Lors d’un survol, le pointeur se déplace et reste sur la cible, même si vous utilisez la souris. Cette autorisation vaut pour cette session et expire au redémarrage de DSH.', + de: 'Beim Überfahren eines Ziels bewegt sich der Zeiger und bleibt dort, auch wenn Sie die Maus verwenden. Die Erlaubnis gilt nur für diese Sitzung und erlischt beim Neustart von DSH.', + ru: 'При наведении указатель переместится и останется на цели, даже если вы пользуетесь мышью. Разрешение действует только в этом сеансе до перезапуска DSH.', + pt: 'Ao passar sobre um elemento, o ponteiro se moverá e ficará no destino, mesmo enquanto você usa o mouse. A permissão vale apenas para esta sessão e expira ao reiniciar o DSH.', + }, + approve: applicationAccessDisplay.approve, +} diff --git a/packages/experimental/cua/src/mouse-control.ts b/packages/experimental/cua/src/mouse-control.ts new file mode 100644 index 0000000000..306cd43a2f --- /dev/null +++ b/packages/experimental/cua/src/mouse-control.ts @@ -0,0 +1,56 @@ +/** Human decisions about the system pointer live in the provider process, keyed by Session. */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' +import { mouseControlDisplay } from './locales.ts' +import { NativeError } from './native-client.ts' + +/** Mouse control applies only to mouseMove and never grants access to an application. */ +export class MouseControl { + private readonly decisions = new Map() + + constructor(private readonly ctx: Context) {} + + /** @param agent Calling Agent. @returns Whether this Session explicitly approved moving the system pointer. */ + allowed(agent: Agent): boolean { + return this.decisions.get(agent.id) === true + } + + /** + * Ask once for the Session. Simultaneous requests cannot open additional questions. + * @param agent Live Agent that owns the Session. + * @param signal Tool lifetime. An aborted tool cannot grant permission. + * @returns True only when the user chose the offered approval. + */ + async request(agent: Agent, signal: AbortSignal): Promise { + const decision = this.decisions.get(agent.id) + if (decision === 'asking') throw new NativeError('MOUSE_CONTROL_PENDING', 'A mouse control request is already awaiting an answer in this session') + if (decision !== undefined) return decision + const questions = this.ctx.get('userQuestions') + if (!questions) throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', 'No human answerer is available for mouse control') + this.decisions.set(agent.id, 'asking') + const id = 'cua-mouse-control' + const approve = mouseControlDisplay.approve.en + const reject = 'Deny' + try { + const result = await questions.ask({ agent, signal, questions: [{ + id, question: mouseControlDisplay.question.en, detail: mouseControlDisplay.detail.en, + options: [{ label: approve }, { label: reject }], + intent: { kind: 'approval', approve, reject, display: mouseControlDisplay }, + }] }) + signal.throwIfAborted() + const [answer] = result.answers + const granted = result.answers.length === 1 && answer?.id === id && !answer.custom?.trim() + && answer.selected.length === 1 && answer.selected[0] === approve + this.decisions.set(agent.id, granted) + return granted + } catch (error) { + if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { + this.decisions.set(agent.id, false) + return false + } + this.decisions.delete(agent.id) + throw error + } + } +} diff --git a/packages/experimental/cua/src/native-schema.ts b/packages/experimental/cua/src/native-schema.ts index a6122ed71e..9cf8435001 100644 --- a/packages/experimental/cua/src/native-schema.ts +++ b/packages/experimental/cua/src/native-schema.ts @@ -1,7 +1,7 @@ /** Build-time native admission metadata derived from the shared operation definitions. */ import { z } from 'zod' import type { JSONSchema } from 'zod/v4/core' -import { NativeOnlyOperations, NativeResourceParameters, Request } from './protocol.ts' +import { NativeOnlyOperations, NativeResourceParameters, NativeToolOperations, type Request } from './protocol.ts' type Schema = JSONSchema.JSONSchema @@ -22,7 +22,7 @@ export function nativeProtocol(platformFields: Partial z.toJSONSchema(schema)) const fields = new Map>() const enums = new Map>>() diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 538c95cfe6..161da04ee7 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -34,8 +34,8 @@ const imageBase64 = z.string().min(1).refine(value => Buffer.from(value, 'base64 message: 'Expected canonical base64 image data', }) -/** Model-submittable operation parameters; Host resource fields and private continuations are separate. */ -export const Request = z.discriminatedUnion('method', [ +/** Operations shared by the model and native helper, before Host parameters are added. */ +export const NativeToolOperations = [ operation('getCapabilities', {}), operation('listApps', {}), operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }), operation('getApp', { selector: z.string().min(1) }), @@ -77,7 +77,10 @@ export const Request = z.discriminatedUnion('method', [ fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(), ]) }).strict(), operation('screenshot', window), -]) +] as const + +/** Model-submittable operations. The Host answers mouse-control requests without dispatching native input. */ +export const Request = z.discriminatedUnion('method', [...NativeToolOperations, operation('requestMouseControl', {})]) /** Resource parameters supplied by the Host, never accepted from model tool arguments. */ export const NativeResourceParameters = { diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index b84663f9e2..541dcdd070 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -246,6 +246,11 @@ export interface CuaApp extends CuaSurface { } /** Native app API; its caller owns global bindings and variable persistence. */ export interface CuaApi { + /** + * Ask to move the user's pointer with mouseMove for this session until restart. + * @returns True only after explicit approval. A refusal is remembered for the session. + */ + requestMouseControl(): Promise /** * Inspect permissions without requesting them. * @param options - output options. diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index cbaa419951..053010af02 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -890,3 +890,11 @@ it('expands menu contents and details only for the requested observation', async await app.getAccessibilityTree({ includeMenus: false, details: false }) expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'getState', params: { appHandle: selected.appHandle, includeMenus: false, details: false } }) }) + +it('requests mouse control without dispatching a move and returns the decision', async () => { + const dispatch = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const cua = createCuaApi(dispatch) + expect(await cua.requestMouseControl()).toBe(false) + expect(await cua.requestMouseControl()).toBe(true) + expect(dispatch.mock.calls).toEqual(Array(2).fill([{ method: 'requestMouseControl', params: {} }])) +}) diff --git a/packages/experimental/cua/tests/application-approval.client.spec.tsx b/packages/experimental/cua/tests/application-approval.client.spec.tsx index f9ec74239c..b6bc9d3d7c 100644 --- a/packages/experimental/cua/tests/application-approval.client.spec.tsx +++ b/packages/experimental/cua/tests/application-approval.client.spec.tsx @@ -6,6 +6,7 @@ import { afterEach, expect, it, vi } from 'vitest' import type { ApprovalCardInput } from '@deepseek-ai/dsh-client-ui-approval/client' import { PendingQuestion, createWaterfallRequest } from '@deepseek-ai/dsh-client-ui-user-questions/src/client/contract/slots.ts' import { ApplicationApproval, selectApplicationApproval, type ApplicationApprovalProps } from '../src/client/ApplicationApproval.tsx' +import { mouseControlDisplay } from '../src/locales.ts' import { en, zh } from '../src/client/locales.ts' type AskUserQuestionItem = PendingQuestion['questions'][number] @@ -84,3 +85,16 @@ it('uses the original question cancellation and refuses late approval', async () expect(card.canAnswer()).toBe(false) await expect(card.onAllow()).rejects.toThrow('no channel accepts an answer yet') }) + +it('shows the mouse interruption and expiry with only session approval and refusal', () => { + const display = mouseControlDisplay + const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-mouse-control', question: display.question.en, + detail: display.detail.en, intent: { kind: 'approval', approve: display.approve.en, reject: 'Deny', display } }]) + const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null) + render( text.zh } as ApplicationApprovalProps} />) + const card = factory.mock.calls[0]![1] + expect(card).toMatchObject({ title: display.question.zh, description: display.detail.zh, allowLabel: display.approve.zh }) + expect(card.onAlways).toBeUndefined() + expect(card.warning).toBeUndefined() +}) diff --git a/snapshots/session/cua-native/replay.override.json b/snapshots/session/cua-native/replay.override.json index b43a6e20f0..e6bf49a2c3 100644 --- a/snapshots/session/cua-native/replay.override.json +++ b/snapshots/session/cua-native/replay.override.json @@ -1418,6 +1418,72 @@ } ] }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-pointer-request", + "name": "cua", + "arguments": "{\"method\":\"requestMouseControl\",\"params\":{}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, + { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "cua-system-hover", + "name": "cua", + "arguments": "{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + }, { "kind": "chunks", "chunks": [ diff --git a/snapshots/session/cua-native/workspace/native-helper.mjs b/snapshots/session/cua-native/workspace/native-helper.mjs index 1e148d7ab5..e9a972a7eb 100755 --- a/snapshots/session/cua-native/workspace/native-helper.mjs +++ b/snapshots/session/cua-native/workspace/native-helper.mjs @@ -47,7 +47,6 @@ function serve() { const server = createServer({ allowHalfOpen: true }, socket => { sockets.add(socket) let connected = false - let systemPointerHover const connectionId = ++connection const input = createInterface({ input: socket }) const scopedRecord = (event, data = {}) => record(event, { connection: connectionId, ...data }) @@ -59,7 +58,6 @@ function serve() { socket.destroy() return } - systemPointerHover = request.config.systemPointerHover connected = true hadClient = true clearTimeout(startupDeadline) @@ -73,7 +71,7 @@ function serve() { } let response try { - const result = call(request.id, request.method, request.params, systemPointerHover) + const result = call(request.id, request.method, request.params) if (result === undefined) return response = { id: request.id, result } } catch (error) { @@ -124,7 +122,7 @@ function createApplicationState(socket, record, application, captureBroker, conn let observationChanged = false let malformedScreenshot = false function refuse(code, message) { throw Object.assign(new Error(message), { code }) } - function call(requestId, method, params, systemPointerHover) { + function call(requestId, method, params) { record('call', { requestId, method, params }) if (method === 'applicationIcons') return Object.fromEntries(params.paths.map(path => [path, `data:image/png;base64,${png}`])) if (method === 'openPermissionSettings') return { opened: true } @@ -204,11 +202,9 @@ function createApplicationState(socket, record, application, captureBroker, conn let systemPointerMoved = false if (method === 'mouseMove') { const alternate = params.elementId === '0' || (params.x >= 20 && params.x <= 40) - if (!alternate && !application.hovering) { - if (!(systemPointerHover ?? params.allowSystemPointer)) refuse('HOVER_UNAVAILABLE', 'No AX alternate UI at this target. No input was sent and the pointer did not move.') - systemPointerMoved = true - } - application.hovering = alternate + systemPointerMoved = params.useSystemPointer === true + if (!systemPointerMoved && !alternate && !application.hovering) refuse('HOVER_UNAVAILABLE', 'No AX alternate UI at this target. No input was sent and the pointer did not move.') + application.hovering = !systemPointerMoved && alternate } else if (method === 'click' && params.windowId === 8) application.visualCount++ else if (method === 'click') application.count++ From 256e1ae370e2668dd1998e7021525eb72a1020b3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 12:30:49 +0800 Subject: [PATCH 34/81] feat(forge): read pull request review threads at the displayed head ForgeProvider gains the optional readReview(ref, headSha, signal), which returns ForgeReviewData only when the read names headSha as the head and a stale failure otherwise. Each thread carries a current position on the head and the original position it was written at; ForgeFailure gains the unsupported code that ctx.forge.readReview returns for a provider without review reads. forge-github reads the threads, the head, and the viewer in one GraphQL query, pages threads up to maxReviewThreads and comments to their end, re-checks the head on every following thread page, and bounds all pages by maxOutputBytes. A thread is placed at the head only when GitHub keeps it current and its first comment is on the head commit. The forge panel adds the changeRequestReview Remote, cached per change request by head and update time and read again after reviewRefreshMs, since resolving a thread does not change a pull request's updatedAt. --- .../src/client/ChangeRequestCard.tsx | 1 + .../experimental/client-ui-forge/src/index.ts | 69 ++++- .../client-ui-forge/src/review-cache.ts | 73 +++++ .../experimental/client-ui-forge/src/types.ts | 16 +- .../tests/review-cache.host.spec.ts | 51 ++++ .../client-ui-forge/tests/review.host.spec.ts | 137 ++++++++++ .../client-ui-forge/tsconfig.host.json | 1 + .../experimental/forge-github/src/provider.ts | 20 +- .../experimental/forge-github/src/threads.ts | 209 ++++++++++++++ .../forge-github/tests/threads.spec.ts | 256 ++++++++++++++++++ packages/experimental/forge/src/index.ts | 36 ++- packages/experimental/forge/src/types.ts | 90 +++++- .../experimental/forge/tests/registry.spec.ts | 16 ++ .../experimental/forge/tests/watch.spec.ts | 25 +- 14 files changed, 980 insertions(+), 20 deletions(-) create mode 100644 packages/experimental/client-ui-forge/src/review-cache.ts create mode 100644 packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/review.host.spec.ts create mode 100644 packages/experimental/forge-github/src/threads.ts create mode 100644 packages/experimental/forge-github/tests/threads.spec.ts diff --git a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx index 5a90b9e6b3..a5f1abbab2 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx @@ -76,6 +76,7 @@ const FAILURE: Record = { 'unauthenticated': 'change.failure.unauthenticated', 'too-large': 'change.failure.unavailable', 'stale': 'change.failure.unavailable', + 'unsupported': 'change.failure.unavailable', 'unavailable': 'change.failure.unavailable', } diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index 708eb757f4..6a1baaa71d 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -6,8 +6,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-api-remote-access' import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { - ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeReviewRequest, ForgeReviewResult, - MergeMethod, + ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeReviewData, ForgeReviewRequest, + ForgeReviewResult, MergeMethod, } from '@deepseek-ai/dsh-experimental-forge' import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session' import type { WorktreeCleanup } from '@deepseek-ai/dsh-experimental-worktree/types' @@ -30,6 +30,7 @@ import { rangeStart } from './commit-range.ts' import { ATTRIBUTES_PATH, FileLayers } from './layers.ts' import { missingWorktree, SessionPanel } from './panel.ts' import { PatchCache } from './patch-cache.ts' +import { ReviewCache } from './review-cache.ts' import { ChangeRequestStates, changeRequestStatesDomain } from './states.ts' import type { PanelConfig } from './panel.ts' import type { @@ -85,6 +86,8 @@ export class ForgePanelController extends TypertRemoteService { maxCommits: z.number().default(250), testPaths: z.array(z.string()).default([]), interfacePaths: z.array(z.string()).default([]), + maxCachedReviewBytes: z.number().default(4_194_304), + reviewRefreshMs: z.number().default(60_000), }) private readonly home: Context @@ -94,6 +97,7 @@ export class ForgePanelController extends TypertRemoteService { private creations!: WorktreeCreations private readonly patches: PatchCache private readonly layers: FileLayers + private readonly reviews: ReviewCache private states: ChangeRequestStates | undefined private readonly stopPanels: () => Promise private readonly stopStates: () => Promise @@ -102,13 +106,13 @@ export class ForgePanelController extends TypertRemoteService { super(ctx, 'forgePanel') const resolved = config as Required if (resolved.gitCommand.trim().length === 0) throw new Error('forge-panel: gitCommand must not be empty') - for (const key of ['gitTimeoutMs', 'graceMs', 'maxOutputBytes', 'readinessTimeoutMs'] as const) { + for (const key of ['gitTimeoutMs', 'graceMs', 'maxOutputBytes', 'readinessTimeoutMs', 'reviewRefreshMs'] as const) { const value = resolved[key] if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { throw new Error(`forge-panel: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) } } - for (const key of ['maxBranches', 'maxDiffBytes', 'maxUntrackedFiles', 'maxCachedDiffBytes', 'maxCommits'] as const) { + for (const key of ['maxBranches', 'maxDiffBytes', 'maxUntrackedFiles', 'maxCachedDiffBytes', 'maxCommits', 'maxCachedReviewBytes'] as const) { if (!Number.isSafeInteger(resolved[key]) || resolved[key] <= 0) throw new Error(`forge-panel: ${key} must be a positive integer`) } if (!branchPrefix(resolved.worktreePrefix)) throw new Error('forge-panel: worktreePrefix must start a valid Git branch name') @@ -119,6 +123,7 @@ export class ForgePanelController extends TypertRemoteService { this.home = ctx this.config = resolved this.patches = new PatchCache(resolved.maxCachedDiffBytes) + this.reviews = new ReviewCache(resolved.maxCachedReviewBytes, resolved.reviewRefreshMs) this.stopPanels = ctx.effect(() => async () => { const panels = [...this.panels.values()] this.panels.clear() @@ -458,6 +463,42 @@ export class ForgePanelController extends TypertRemoteService { return text } + /** + * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read + * at the same head commit and update time are reused from memory, for any Session, until they are + * `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved + * since, which do not change a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param headSha - head commit of the displayed diff. + * @param signal - request cancellation. + * @returns the threads at `headSha`, with `current` positions numbering the files of that diff. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; + * `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the + * status was read and when the host does not provide the threads otherwise. + */ + @Remote + async changeRequestReview(session: Session, url: string, headSha: string, signal: AbortSignal): Promise { + signal.throwIfAborted() + const { ref, update } = this.bound(session, url) + if (update === null || !('snapshot' in update)) { + throw new RemoteError('forgePanel/review-failed', 'The change request status has not been read yet', {}) + } + if (update.snapshot.headSha !== headSha) { + throw new RemoteError('forgePanel/stale-head', `The change request head moved from ${headSha} to ${update.snapshot.headSha}`, {}) + } + const key = changeRequestKey(ref) + const version = JSON.stringify([headSha, update.snapshot.updatedAt]) + const readAt = Date.now() + const kept = this.reviews.get(key, version, readAt) + if (kept !== undefined) return kept + const review = await this.home.forge.readReview(ref, headSha, signal) + if ('code' in review) throw reviewError(review) + this.reviews.set(key, version, review, readAt) + return review + } + /** * Publish a line comment or completed review for a visible Session binding. * @param session - Session retaining the change request. @@ -822,12 +863,32 @@ function readError(failure: ForgeFailure): RemoteError { case 'stale': return new RemoteError('forgePanel/stale-head', failure.message, {}) case 'not-found': case 'unauthenticated': + case 'unsupported': case 'unavailable': return new RemoteError('forgePanel/diff-failed', failure.message, {}) /* v8 ignore next -- closed ForgeFailure code union exhaustiveness guard */ default: return assertNever(failure.code, 'ForgeFailure.code') } } +/** + * Report why the code host did not provide review threads. + * @param failure - the provider's failure. + * @returns `forgePanel/stale-head` for a head that moved, `forgePanel/review-unsupported` for a provider without + * review reads, else `forgePanel/review-failed`, with the provider's message. + */ +function reviewError(failure: ForgeFailure): RemoteError { + switch (failure.code) { + case 'stale': return new RemoteError('forgePanel/stale-head', failure.message, {}) + case 'unsupported': return new RemoteError('forgePanel/review-unsupported', failure.message, {}) + case 'not-found': + case 'unauthenticated': + case 'too-large': + case 'unavailable': return new RemoteError('forgePanel/review-failed', failure.message, {}) + /* v8 ignore next -- closed ForgeFailure code union exhaustiveness guard */ + default: return assertNever(failure.code, 'ForgeFailure.code') + } +} + /** * Settle a diff read, reporting its size limit, its filter configuration, a missing fork point, a missing base branch, and chosen * commits that are not on the branch as Remote errors. diff --git a/packages/experimental/client-ui-forge/src/review-cache.ts b/packages/experimental/client-ui-forge/src/review-cache.ts new file mode 100644 index 0000000000..6a8d9f0d1e --- /dev/null +++ b/packages/experimental/client-ui-forge/src/review-cache.ts @@ -0,0 +1,73 @@ +/** + * Review threads of change requests kept in memory, so the changes view reads them from the code host again only + * after the change request's head or update time changes or the kept threads reach their age limit. + */ + +import { Buffer } from 'node:buffer' +import type { ChangeRequestKey, ForgeReviewData } from '@deepseek-ai/dsh-experimental-forge' + +interface Entry { + /** Head commit and update time the threads were read at. */ + readonly version: string + readonly review: ForgeReviewData + /** Epoch milliseconds of the read. */ + readonly readAt: number + /** UTF-8 size of the threads as JSON. */ + readonly bytes: number +} + +/** + * One read of review threads per change request, bounded by age and by the total UTF-8 size of the kept reads as + * JSON; the least recently read change request leaves first. + */ +export class ReviewCache { + private readonly entries = new Map() + private bytes = 0 + + /** + * @param maxBytes - most UTF-8 bytes kept together; a larger read is not kept. + * @param maxAgeMs - age in milliseconds at which a kept read is no longer returned. + */ + constructor(private readonly maxBytes: number, private readonly maxAgeMs: number) {} + + /** + * Read the threads kept for a change request at a version while they are younger than the age limit, marking them + * the most recently read. + * @param key - the change request. + * @param version - head commit and update time the reader needs. + * @param now - current epoch milliseconds. + * @returns the threads, or undefined when none are kept at that version or they reached the age limit. + */ + get(key: ChangeRequestKey, version: string, now: number): ForgeReviewData | undefined { + const entry = this.entries.get(key) + if (entry?.version !== version || now - entry.readAt >= this.maxAgeMs) return undefined + this.entries.delete(key) + this.entries.set(key, entry) + return entry.review + } + + /** + * Keep a read in place of the change request's earlier one, then drop the least recently read change requests + * until the rest fit within `maxBytes`. + * @param key - the change request. + * @param version - head commit and update time the threads were read at. + * @param review - the threads. + * @param readAt - epoch milliseconds when the read started. + */ + set(key: ChangeRequestKey, version: string, review: ForgeReviewData, readAt: number): void { + const previous = this.entries.get(key) + if (previous !== undefined) { + this.entries.delete(key) + this.bytes -= previous.bytes + } + const bytes = Buffer.byteLength(JSON.stringify(review)) + if (bytes > this.maxBytes) return + this.entries.set(key, { version, review, readAt, bytes }) + this.bytes += bytes + for (const [oldest, entry] of this.entries) { + if (this.bytes <= this.maxBytes) break + this.entries.delete(oldest) + this.bytes -= entry.bytes + } + } +} diff --git a/packages/experimental/client-ui-forge/src/types.ts b/packages/experimental/client-ui-forge/src/types.ts index 11dc7549e3..1588277207 100644 --- a/packages/experimental/client-ui-forge/src/types.ts +++ b/packages/experimental/client-ui-forge/src/types.ts @@ -414,6 +414,13 @@ export interface Config { * `testPaths` and before the built-in rules. */ interfacePaths?: string[] + /** Most UTF-8 bytes, as JSON, of change request review threads kept in memory for later reads at the same head commit and update time. */ + maxCachedReviewBytes?: number + /** + * Age in milliseconds at which review threads kept in memory are read from the code host again; a GitHub pull + * request's update time does not change when a thread is resolved. + */ + reviewRefreshMs?: number } declare module '@deepseek-ai/dsh-typert-protocol' { @@ -438,8 +445,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'forgePanel/not-bound': Record /** * The change request's head is no longer the commit the request named: the head the user confirmed for a merge, - * the head displayed for complete checks, or the head of the latest status for a diff. A diff read polls the change - * request again at once, so a later status names the current head. + * the head displayed for complete checks, the head of the latest status for a diff, or the head of the displayed + * diff for review threads. A diff or review read the host refuses polls the change request again at once, so a + * later status names the current head. */ 'forgePanel/stale-head': Record /** The code host refused the change or could not be reached; the message gives the host's reason. */ @@ -468,6 +476,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'forgePanel/commits-missing': Record /** A chosen commit after the first lacks the commit listed before it as its first parent, so two ends cannot name the run. */ 'forgePanel/commits-not-linear': Record + /** The code host's provider cannot read review threads, so the changes view shows none. */ + 'forgePanel/review-unsupported': Record + /** The code host did not provide the change request's review threads, or its status has not been read yet; the message says why. */ + 'forgePanel/review-failed': Record } } diff --git a/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts b/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts new file mode 100644 index 0000000000..de498b8bbf --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { Buffer } from 'node:buffer' +import type { ChangeRequestKey, ForgeReviewData } from '@deepseek-ai/dsh-experimental-forge' +import { ReviewCache } from '../src/review-cache.ts' + +const a = 'fake:a#1' as ChangeRequestKey +const b = 'fake:b#2' as ChangeRequestKey +const c = 'fake:c#3' as ChangeRequestKey + +function review(viewer: string): ForgeReviewData { + return { headSha: 'a'.repeat(40), viewer, threads: [], truncated: false } +} + +const size = Buffer.byteLength(JSON.stringify(review('x'))) + +describe('ReviewCache', () => { + it('returns threads kept at the same version until they reach the age limit', () => { + const cache = new ReviewCache(size * 4, 100) + cache.set(a, 'v1', review('x'), 1_000) + + expect(cache.get(a, 'v1', 1_099)).toEqual(review('x')) + expect(cache.get(a, 'v2', 1_050)).toBeUndefined() + expect(cache.get(b, 'v1', 1_050)).toBeUndefined() + expect(cache.get(a, 'v1', 1_100)).toBeUndefined() + }) + + it('replaces a change request\'s earlier read and drops the least recently read reads past the byte limit', () => { + const cache = new ReviewCache(size * 2, 100) + cache.set(a, 'v1', review('x'), 0) + cache.set(a, 'v2', review('y'), 0) + cache.set(b, 'v1', review('z'), 0) + expect(cache.get(a, 'v1', 0)).toBeUndefined() + expect(cache.get(a, 'v2', 0)).toEqual(review('y')) + + cache.set(c, 'v1', review('w'), 0) + expect(cache.get(b, 'v1', 0)).toBeUndefined() + expect(cache.get(a, 'v2', 0)).toEqual(review('y')) + expect(cache.get(c, 'v1', 0)).toEqual(review('w')) + }) + + it('keeps no read larger than the byte limit and forgets the read it replaced', () => { + const cache = new ReviewCache(size, 100) + cache.set(a, 'v1', review('x'), 0) + cache.set(a, 'v2', review('longer'), 0) + + expect(cache.get(a, 'v1', 0)).toBeUndefined() + expect(cache.get(a, 'v2', 0)).toBeUndefined() + cache.set(b, 'v1', review('x'), 0) + expect(cache.get(b, 'v1', 0)).toEqual(review('x')) + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/review.host.spec.ts b/packages/experimental/client-ui-forge/tests/review.host.spec.ts new file mode 100644 index 0000000000..6869d3acb6 --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/review.host.spec.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ChangeRequestRef, ForgeFailure, ForgeReviewData, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge' +import { SessionId } from '@deepseek-ai/dsh-session' +import ForgePanelController from '../src/index.ts' +import type { ForgePanelState } from '../src/index.ts' +import { bench, follow, git, pullUrl, repository, scratch, shellTurn } from './harness.ts' +import type { Bench, Follower } from './harness.ts' + +const cleanups: (() => Promise)[] = [] + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +const signal = new AbortController().signal +const HEAD = 'a'.repeat(40) + +async function opened(config: Parameters[1] = {}): Promise<{ opened: Bench; follower: Follower; repo: string }> { + const { path, remove } = await scratch('dsh-forge-review-') + cleanups.push(remove) + const repo = await repository(path) + await git(repo, 'remote', 'add', 'origin', 'https://fake.test/octo/demo.git') + const result = await bench(repo, config) + cleanups.push(() => result.ctx.fiber.dispose()) + const follower = follow(result.ctx, result.session) + cleanups.push(follower.stop) + return { opened: result, follower, repo } +} + +function review(headSha: string, path = 'src/a.ts'): ForgeReviewData { + return { + headSha, viewer: 'octo', truncated: false, + threads: [{ + id: 'thread-1' as ForgeThreadId, path, side: 'R', subject: 'line', resolved: false, diffHunk: '@@ -1 +1 @@\n-old\n+new', + current: { commit: headSha, startLine: 1, endLine: 1 }, original: { commit: headSha, startLine: 1, endLine: 1 }, comments: [], + }], + } +} + +/** Whether the change request at `url` holds a snapshot with the given head and update time. */ +function polledAt(state: ForgePanelState, url: string, headSha = HEAD, updatedAt?: string): boolean { + const snapshot = state.changeRequests.find(entry => entry.url === url)?.snapshot ?? null + return snapshot !== null && snapshot.headSha === headSha && (updatedAt === undefined || snapshot.updatedAt === updatedAt) +} + +describe('forgePanel change request reviews', () => { + it('reads threads at the displayed head and reuses them for every Session until the update time changes', async () => { + const { opened: panel, follower, repo } = await opened() + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + Object.assign(panel.provider, { readReview }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal)).resolves.toEqual(review(HEAD)) + const other = panel.ctx.sessions.create(SessionId('forge-review-other'), { meta: { cwd: repo } }) + panel.ctx.forgeSession.track(other, pullUrl(7)) + await expect(panel.ctx.forgePanel.changeRequestReview(other, pullUrl(7), HEAD, signal)).resolves.toEqual(review(HEAD)) + expect(readReview).toHaveBeenCalledTimes(1) + + panel.provider.overrides.set(7, { updatedAt: '2026-11-07T05:00:00Z' }) + await panel.ctx.forgePanel.refresh(panel.session, signal) + await follower.until(state => polledAt(state, pullUrl(7), HEAD, '2026-11-07T05:00:00Z')) + readReview.mockResolvedValueOnce(review(HEAD, 'src/b.ts')) + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal)) + .resolves.toMatchObject({ threads: [{ path: 'src/b.ts' }] }) + expect(readReview).toHaveBeenCalledTimes(2) + }) + + it('reads again once the kept threads reach reviewRefreshMs', async () => { + const { opened: panel, follower } = await opened({ reviewRefreshMs: 1 }) + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + Object.assign(panel.provider, { readReview }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + await new Promise(resolve => setTimeout(resolve, 5)) + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + expect(readReview).toHaveBeenCalledTimes(2) + }) + + it('refuses a head other than the latest status without reading, and polls again when the host names another head', async () => { + const { opened: panel, follower } = await opened() + const pushed = 'b'.repeat(40) + const readReview = vi.fn(async (): Promise => ({ code: 'stale', message: `head moved to ${pushed}` })) + Object.assign(panel.provider, { readReview }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), pushed, signal)) + .rejects.toMatchObject({ code: 'forgePanel/stale-head', message: `The change request head moved from ${pushed} to ${HEAD}` }) + expect(readReview).not.toHaveBeenCalled() + + panel.provider.overrides.set(7, { headSha: pushed }) + const fetches = panel.provider.fetches.length + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal)) + .rejects.toMatchObject({ code: 'forgePanel/stale-head', message: `head moved to ${pushed}` }) + await follower.until(state => polledAt(state, pullUrl(7), pushed)) + expect(panel.provider.fetches.length).toBe(fetches + 1) + }) + + it('reports an unbound change request, one not read yet, a provider without review reads, and a failed read, keeping no failure', async () => { + const { opened: panel, follower } = await opened() + panel.provider.failures.set(8, { code: 'unavailable', message: 'down' }) + shellTurn(panel.session, 1, [ + { callId: 'seven', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }, + { callId: 'eight', command: 'gh pr create --fill', output: `${pullUrl(8)}\n` }, + ]) + await follower.until(state => polledAt(state, pullUrl(7)) + && state.changeRequests.some(entry => entry.url === pullUrl(8) && entry.update !== null && 'failure' in entry.update)) + const read = (url: string): Promise => panel.ctx.forgePanel.changeRequestReview(panel.session, url, HEAD, signal) + + await expect(read(pullUrl(9))).rejects.toMatchObject({ code: 'forgePanel/not-bound' }) + await expect(read(pullUrl(8))).rejects.toMatchObject({ code: 'forgePanel/review-failed', message: 'The change request status has not been read yet' }) + await expect(read(pullUrl(7))) + .rejects.toMatchObject({ code: 'forgePanel/review-unsupported', message: 'This provider does not support review reads' }) + const codes = ['too-large', 'not-found', 'unauthenticated', 'unavailable'] as const + const readReview = vi.fn(async (): Promise => ({ code: 'unavailable', message: 'down' })) + Object.assign(panel.provider, { readReview }) + for (const code of codes) { + readReview.mockResolvedValueOnce({ code, message: `threads ${code}` }) + await expect(read(pullUrl(7))).rejects.toMatchObject({ code: 'forgePanel/review-failed', message: `threads ${code}` }) + } + expect(readReview).toHaveBeenCalledTimes(codes.length) + }) + + it.each([ + [{ reviewRefreshMs: 0 }, 'forge-panel: reviewRefreshMs must be a positive integer no greater than 2147483647'], + [{ reviewRefreshMs: 2_147_483_648 }, 'forge-panel: reviewRefreshMs must be a positive integer no greater than 2147483647'], + [{ maxCachedReviewBytes: 1.5 }, 'forge-panel: maxCachedReviewBytes must be a positive integer'], + ])('rejects %j at load', async (config, message) => { + const { opened: panel } = await opened() + await panel.panel.dispose() + + await expect(panel.ctx.plugin(ForgePanelController, config)).rejects.toThrow(message) + }) +}) diff --git a/packages/experimental/client-ui-forge/tsconfig.host.json b/packages/experimental/client-ui-forge/tsconfig.host.json index a6c004f078..051f252042 100644 --- a/packages/experimental/client-ui-forge/tsconfig.host.json +++ b/packages/experimental/client-ui-forge/tsconfig.host.json @@ -15,6 +15,7 @@ "src/panel.ts", "src/parse.ts", "src/patch-cache.ts", + "src/review-cache.ts", "src/states.ts", "src/types.ts", "src/wildmatch.ts", diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index 67e963e3d9..cf6905149e 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -3,7 +3,7 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeProvider, - ForgeReadiness, ForgeRepository, ForgeReviewRequest, ForgeReviewResult, MergeRequest, + ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, MergeRequest, } from '@deepseek-ai/dsh-experimental-forge' import { listCommits } from './commits.ts' import type { GraphqlReader } from './commits.ts' @@ -17,6 +17,7 @@ import { import type { PageResult, SnapshotPages } from './graphql.ts' import { checkedRepository, isCommit, parsePullRequestUrl, parseRemoteUrl } from './urls.ts' import { submitGitHubReview } from './review.ts' +import { readReviewThreads } from './threads.ts' const AUTHENTICATION = /gh auth login|authentication|bad credentials|HTTP 401/i @@ -34,9 +35,9 @@ const NOT_ACCEPTABLE = '406' /** Bounds on what one read retains or counts. */ export interface GitHubLimits { - /** Maximum combined JSON bytes retained by an explicit check read. */ + /** Maximum combined JSON bytes retained by an explicit check read or a review read. */ readonly maxOutputBytes: number - /** Most review threads counted per pull request in one status read. */ + /** Most review threads counted per pull request in one status read, and read in one review read. */ readonly maxReviewThreads: number /** Most commits one commit listing returns. */ readonly maxCommits: number @@ -53,10 +54,21 @@ export class GitHubProvider implements ForgeProvider { return await submitGitHubReview(this.gh, ref, request, signal) } + /** + * Read the review threads of a pull request at `headSha` through GraphQL, each page naming the head it was read at. + * @see readReviewThreads + */ + async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { + const failure = this.hostFailure(ref.repository.host) + if (failure !== undefined) return failure + if (!isCommit(headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(headSha)}`) + return await readReviewThreads(this.graphql, ref, headSha, this.limits, signal) + } + /** * @param gh - runner that executes `gh` on the host. * @param hosts - host names this provider serves, such as `github.com` and Enterprise hosts. - * @param limits - bounds on retained check bytes, counted review threads, and listed commits. + * @param limits - bounds on retained check and review bytes, counted and read review threads, and listed commits. */ constructor(private readonly gh: GhRunner, hosts: readonly string[], private readonly limits: GitHubLimits) { this.hosts = new Set(hosts.map(host => host.toLowerCase())) diff --git a/packages/experimental/forge-github/src/threads.ts b/packages/experimental/forge-github/src/threads.ts new file mode 100644 index 0000000000..f23507e374 --- /dev/null +++ b/packages/experimental/forge-github/src/threads.ts @@ -0,0 +1,209 @@ +/** Reads the review threads of a GitHub pull request at one head commit through GraphQL. */ + +import { Buffer } from 'node:buffer' +import type { + ChangeRequestRef, ForgeAnchor, ForgeCommentId, ForgeFailure, ForgeReviewComment, ForgeReviewData, ForgeReviewThread, ForgeThreadId, +} from '@deepseek-ai/dsh-experimental-forge' +import { z } from 'zod' +import type { GraphqlReader } from './commits.ts' +import { checkedRepository, isCommit } from './urls.ts' + +/** Most nodes GitHub returns in one connection page. */ +const PAGE_SIZE = 100 + +const COMMENT_FIELDS = 'id state createdAt url body author { __typename login avatarUrl(size: 48) }' + +const COMMENT_PAGE = `comments(first: ${PAGE_SIZE}, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT_FIELDS} } }` + +/** + * Thread fields. `root` reads the commits and diff hunk of the first comment once, since replies repeat them; the + * first page of comments starts without a cursor. + */ +const THREAD_FIELDS = `id isOutdated isResolved path diffSide startDiffSide line startLine originalLine originalStartLine subjectType + root: comments(first: 1) { nodes { diffHunk commit { oid } originalCommit { oid } } } + ${COMMENT_PAGE.replace(', after: $cursor', '')}` + +const THREAD_PAGE = `pageInfo { hasNextPage endCursor } nodes { ${THREAD_FIELDS} }` + +/** First thread page with the head and the viewer; variables `owner` and `name` are strings, `number` and `first` integers. */ +export const REVIEW_QUERY = `query($owner: String!, $name: String!, $number: Int!, $first: Int!) { + viewer { login } + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id headRefOid reviewThreads(first: $first) { ${THREAD_PAGE} } } } +}` + +/** Following thread page with the head it was read at; variables `id` and `cursor` are strings, `first` an integer. */ +export const REVIEW_THREADS_QUERY = `query($id: ID!, $cursor: String!, $first: Int!) { + node(id: $id) { ... on PullRequest { headRefOid reviewThreads(first: $first, after: $cursor) { ${THREAD_PAGE} } } } +}` + +/** Following comment page of one thread; variables `id` and `cursor` are strings. */ +export const THREAD_COMMENTS_QUERY = `query($id: ID!, $cursor: String!) { + node(id: $id) { ... on PullRequestReviewThread { ${COMMENT_PAGE} } } +}` + +/** Bounds of one review read. */ +export interface ReviewLimits { + /** Most threads read. */ + readonly maxReviewThreads: number + /** Most JSON bytes of all pages together; a page that would pass it ends the read as truncated. */ + readonly maxOutputBytes: number +} + +const pageInfoSchema = z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }) + +const commitSchema = z.object({ oid: z.string().refine(isCommit) }).nullable() + +const commentSchema = z.object({ + id: z.string(), + state: z.string(), + createdAt: z.string(), + url: z.string().nullable(), + body: z.string(), + author: z.object({ __typename: z.string(), login: z.string(), avatarUrl: z.string().nullable() }).nullable(), +}) + +const commentPageSchema = z.object({ pageInfo: pageInfoSchema, nodes: z.array(commentSchema) }) + +const threadSchema = z.object({ + id: z.string(), + isOutdated: z.boolean(), + isResolved: z.boolean(), + path: z.string(), + diffSide: z.string(), + startDiffSide: z.string().nullable(), + line: z.number().int().nullable(), + startLine: z.number().int().nullable(), + originalLine: z.number().int().nullable(), + originalStartLine: z.number().int().nullable(), + subjectType: z.string(), + root: z.object({ nodes: z.array(z.object({ diffHunk: z.string(), commit: commitSchema, originalCommit: commitSchema })) }), + comments: commentPageSchema, +}) + +const threadPageSchema = z.object({ pageInfo: pageInfoSchema, nodes: z.array(threadSchema) }) + +const firstPageSchema = z.object({ data: z.object({ + viewer: z.object({ login: z.string() }).nullable(), + repository: z.object({ pullRequest: z.object({ id: z.string(), headRefOid: z.string(), reviewThreads: threadPageSchema }) }), +}) }) + +const nextPageSchema = z.object({ data: z.object({ node: z.object({ headRefOid: z.string(), reviewThreads: threadPageSchema }) }) }) + +const commentsPageSchema = z.object({ data: z.object({ node: z.object({ comments: commentPageSchema }) }) }) + +type ThreadNode = z.infer +type CommentNode = z.infer + +/** + * Read the review threads of a pull request while its head is `headSha`. The first page also names the head and the + * signed-in user; every following thread page names the head again, so a head that moves during the read returns + * `stale`. Threads are read up to `maxReviewThreads`, each with all of its comments. A page that would bring the + * pages read, measured as JSON, past `maxOutputBytes` is dropped with the threads after it, and the result is marked truncated. + * @param read - runs one GraphQL request. + * @param ref - pull request, with an owner and name already checked for inlining. + * @param headSha - head commit the threads must be read at. + * @param limits - thread and byte bounds. + * @param signal - cancellation of the read. + * @returns the threads, or why GitHub did not provide them at `headSha`. + */ +export async function readReviewThreads( + read: GraphqlReader, ref: ChangeRequestRef, headSha: string, limits: ReviewLimits, signal: AbortSignal, +): Promise { + const { host, owner, name } = checkedRepository(ref) + const graphql = async (variables: readonly string[]): Promise<{ readonly body: unknown; readonly bytes: number } | ForgeFailure> => { + const output = await read(['api', 'graphql', '--hostname', host, ...variables], signal) + if (!output.ok) return output.failure + return { body: output.body, bytes: Buffer.byteLength(JSON.stringify(output.body)) } + } + const unexpected: ForgeFailure = { code: 'unavailable', message: 'unexpected GitHub response to the review thread read' } + const stale = (head: string): ForgeFailure | undefined => head.toLowerCase() === headSha.toLowerCase() ? undefined + : { code: 'stale', message: `the pull request head moved from ${headSha} to ${head}` } + const first = await graphql(['-f', `query=${REVIEW_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, + '-F', `number=${ref.number}`, '-F', `first=${Math.min(PAGE_SIZE, limits.maxReviewThreads)}`]) + if ('code' in first) return first + const parsed = firstPageSchema.safeParse(first.body) + if (!parsed.success) return unexpected + const pull = parsed.data.data.repository.pullRequest + const moved = stale(pull.headRefOid) + if (moved !== undefined) return moved + let bytes = first.bytes + let page = pull.reviewThreads + const threads: ForgeReviewThread[] = [] + const viewer = parsed.data.data.viewer?.login ?? null + const result = (truncated: boolean): ForgeReviewData => ({ headSha, viewer, threads, truncated }) + const cursors = new Set() + for (;;) { + for (const node of page.nodes.slice(0, limits.maxReviewThreads - threads.length)) { + const comments = [...node.comments.nodes] + const commentCursors = new Set() + let more = node.comments.pageInfo + while (more.hasNextPage) { + const cursor = more.endCursor + if (cursor === null || commentCursors.has(cursor)) return { code: 'unavailable', message: 'GitHub review comment pagination did not advance' } + commentCursors.add(cursor) + const next = await graphql(['-f', `query=${THREAD_COMMENTS_QUERY}`, '-f', `id=${node.id}`, '-f', `cursor=${cursor}`]) + if ('code' in next) return next + const comment = commentsPageSchema.safeParse(next.body) + if (!comment.success) return unexpected + bytes += next.bytes + if (bytes > limits.maxOutputBytes) return result(true) + comments.push(...comment.data.data.node.comments.nodes) + more = comment.data.data.node.comments.pageInfo + } + if (comments.length > 0) threads.push(toThread(node, comments, headSha)) + } + if (threads.length >= limits.maxReviewThreads || !page.pageInfo.hasNextPage) return result(page.pageInfo.hasNextPage) + const cursor = page.pageInfo.endCursor + if (cursor === null || cursors.has(cursor)) return { code: 'unavailable', message: 'GitHub review thread pagination did not advance' } + cursors.add(cursor) + const next = await graphql(['-f', `query=${REVIEW_THREADS_QUERY}`, '-f', `id=${pull.id}`, '-f', `cursor=${cursor}`, + '-F', `first=${Math.min(PAGE_SIZE, limits.maxReviewThreads - threads.length)}`]) + if ('code' in next) return next + const following = nextPageSchema.safeParse(next.body) + if (!following.success) return unexpected + const movedLater = stale(following.data.data.node.headRefOid) + if (movedLater !== undefined) return movedLater + bytes += next.bytes + if (bytes > limits.maxOutputBytes) return result(true) + page = following.data.data.node.reviewThreads + } +} + +/** + * Map one thread. GitHub keeps a thread's `line` on the commit of its first comment, which stays an older commit for + * a comment written on a commit the head no longer is; such a thread, like one GitHub marks outdated or leaves + * without a line, has no position at `head`. A range whose start is on the other side of the diff keeps its last line. + */ +function toThread(node: ThreadNode, comments: readonly CommentNode[], head: string): ForgeReviewThread { + const root = node.root.nodes[0] + const line = node.subjectType !== 'FILE' + const sameSide = node.startDiffSide === null || node.startDiffSide === node.diffSide + const placed = line && !node.isOutdated && node.line !== null && root?.commit?.oid.toLowerCase() === head.toLowerCase() + return { + id: node.id as ForgeThreadId, + path: node.path, + side: node.diffSide === 'LEFT' ? 'L' : 'R', + subject: line ? 'line' : 'file', + current: placed && node.line !== null ? anchor(head, sameSide ? node.startLine : null, node.line) : null, + original: line && node.originalLine !== null && root?.originalCommit != null + ? anchor(root.originalCommit.oid, sameSide ? node.originalStartLine : null, node.originalLine) : null, + diffHunk: root === undefined || root.diffHunk === '' ? null : root.diffHunk, + resolved: node.isResolved, + comments: comments.map(toComment), + } +} + +function anchor(commit: string, start: number | null, end: number): ForgeAnchor { + return { commit, startLine: start !== null && start >= 1 && start <= end ? start : end, endLine: end } +} + +function toComment(node: CommentNode): ForgeReviewComment { + return { + id: node.id as ForgeCommentId, + author: node.author === null ? null : { login: node.author.login, avatarUrl: node.author.avatarUrl, bot: node.author.__typename === 'Bot' }, + body: node.body, + createdAt: node.createdAt, + pending: node.state === 'PENDING', + url: node.url, + } +} diff --git a/packages/experimental/forge-github/tests/threads.spec.ts b/packages/experimental/forge-github/tests/threads.spec.ts new file mode 100644 index 0000000000..f90c18b0a0 --- /dev/null +++ b/packages/experimental/forge-github/tests/threads.spec.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest' +import type { GhOutput, GhRunner } from '../src/gh.ts' +import { GitHubProvider } from '../src/provider.ts' +import type { GitHubLimits } from '../src/provider.ts' +import { REVIEW_QUERY, REVIEW_THREADS_QUERY, THREAD_COMMENTS_QUERY } from '../src/threads.ts' +import { demo, pull } from './fixtures.ts' + +const head = 'c'.repeat(40) +const older = 'a'.repeat(40) +const pushed = 'd'.repeat(40) +const signal = new AbortController().signal + +function output(stdout: unknown, exitCode = 0, stderr = ''): GhOutput { + return { exitCode, stdout: typeof stdout === 'string' ? stdout : JSON.stringify(stdout), stderr } +} + +/** Answer each `gh` call through `handler`, recording the arguments. */ +function fakeGh(handler: (args: readonly string[]) => GhOutput): { run: GhRunner; calls: string[][] } { + const calls: string[][] = [] + return { calls, run: async (args) => { calls.push([...args]); return handler(args) } } +} + +function provider(run: GhRunner, limits: Partial = {}): GitHubProvider { + return new GitHubProvider(run, ['github.com'], { maxOutputBytes: 8_388_608, maxReviewThreads: 1_000, maxCommits: 250, ...limits }) +} + +function page(nodes: readonly unknown[], next: string | null = null): Record { + return { pageInfo: { hasNextPage: next !== null, endCursor: next }, nodes } +} + +/** One review comment node as the thread query selects it. */ +function commentNode(id: string, overrides: Record = {}): Record { + return { + id, state: 'SUBMITTED', createdAt: '2026-11-05T08:00:00Z', url: `https://github.com/octo/demo/pull/7#discussion_${id}`, body: `Body of ${id}`, + author: { __typename: 'User', login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4' }, + ...overrides, + } +} + +/** One review thread node on a new-side line at the head, with one comment. */ +function threadNode(id: string, overrides: Record = {}, root: Record = {}): Record { + return { + id, isOutdated: false, isResolved: false, path: 'src/a.ts', diffSide: 'RIGHT', startDiffSide: null, + line: 12, startLine: null, originalLine: 12, originalStartLine: null, subjectType: 'LINE', + root: { nodes: [{ diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c', commit: { oid: head }, originalCommit: { oid: head }, ...root }] }, + comments: page([commentNode(`${id}-1`)]), + ...overrides, + } +} + +function firstPage(threads: Record, headRefOid = head, viewer: unknown = { login: 'octo' }): Record { + return { data: { viewer, repository: { pullRequest: { id: 'pull-7', headRefOid, reviewThreads: threads } } } } +} + +function nextPage(threads: Record, headRefOid = head): Record { + return { data: { node: { headRefOid, reviewThreads: threads } } } +} + +/** Answer the first thread page with `first`, later thread pages from `later` by cursor, and comment pages from `comments` by cursor. */ +function review( + first: Record | GhOutput, + later: Readonly>> = {}, + comments: Readonly>> = {}, +): ReturnType { + return fakeGh((args) => { + const cursor = args.find(arg => arg.startsWith('cursor='))?.slice('cursor='.length) ?? '' + if (args.includes(`query=${REVIEW_QUERY}`)) return 'exitCode' in first ? first : output(first) + if (args.includes(`query=${REVIEW_THREADS_QUERY}`)) return output(later[cursor] ?? { data: { node: null } }) + if (args.includes(`query=${THREAD_COMMENTS_QUERY}`)) return output(comments[cursor] ?? { data: { node: null } }) + throw new Error(`unexpected gh ${args.join(' ')}`) + }) +} + +describe('GitHub provider review threads', () => { + it('reads the first page with the head, the viewer, and the pull request as variables, and maps a thread placed at the head', async () => { + const gh = review(firstPage(page([threadNode('t1', { isResolved: true })]))) + + await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toEqual({ + headSha: head, + viewer: 'octo', + truncated: false, + threads: [{ + id: 't1', path: 'src/a.ts', side: 'R', subject: 'line', + current: { commit: head, startLine: 12, endLine: 12 }, + original: { commit: head, startLine: 12, endLine: 12 }, + diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c', + resolved: true, + comments: [{ + id: 't1-1', body: 'Body of t1-1', createdAt: '2026-11-05T08:00:00Z', pending: false, + url: 'https://github.com/octo/demo/pull/7#discussion_t1-1', + author: { login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4', bot: false }, + }], + }], + }) + expect(gh.calls).toEqual([['api', 'graphql', '--hostname', 'github.com', '-f', `query=${REVIEW_QUERY}`, + '-f', `owner=${demo.owner}`, '-f', `name=${demo.name}`, '-F', 'number=7', '-F', 'first=100']]) + }) + + it('places a thread GitHub moved to the head at its current lines and keeps the lines it was written on', async () => { + const gh = review(firstPage(page([threadNode('moved', { line: 136, originalLine: 135 }, { originalCommit: { oid: older } })]))) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ threads: [{ + current: { commit: head, startLine: 136, endLine: 136 }, original: { commit: older, startLine: 135, endLine: 135 }, + }] }) + }) + + it.each([ + ['GitHub marks it outdated without a line', { isOutdated: true, line: null }, {}], + ['GitHub marks it outdated but keeps a line', { isOutdated: true, line: 303, startLine: 294 }, {}], + ['GitHub reports no line', { line: null }, {}], + ['its comment is on a commit other than the head', {}, { commit: { oid: older }, originalCommit: { oid: older } }], + ['its comment has no commit', {}, { commit: null }], + ] as const)('gives a thread no current position when %s', async (_label, thread, root) => { + const gh = review(firstPage(page([threadNode('old', { originalLine: 9, ...thread }, root)]))) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ threads: [{ current: null, original: { startLine: 9, endLine: 9 } }] }) + }) + + it('maps a file thread without positions or hunk, an old-side range, and a range that starts on the other side', async () => { + const gh = review(firstPage(page([ + threadNode('file', { subjectType: 'FILE', line: null, originalLine: null }, { diffHunk: '' }), + threadNode('left', { diffSide: 'LEFT', startDiffSide: 'LEFT', startLine: 3, line: 5, originalStartLine: 3, originalLine: 5 }), + threadNode('cross', { diffSide: 'RIGHT', startDiffSide: 'LEFT', startLine: 4, line: 6, originalStartLine: 4, originalLine: 6 }), + threadNode('reversed', { startLine: 8, line: 6, originalStartLine: 0, originalLine: 6 }), + ]))) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ threads: [ + { id: 'file', subject: 'file', side: 'R', current: null, original: null, diffHunk: null }, + { id: 'left', side: 'L', current: { startLine: 3, endLine: 5 }, original: { startLine: 3, endLine: 5 } }, + { id: 'cross', side: 'R', current: { startLine: 6, endLine: 6 }, original: { startLine: 6, endLine: 6 } }, + { id: 'reversed', current: { startLine: 6, endLine: 6 }, original: { startLine: 6, endLine: 6 } }, + ] }) + }) + + it('maps pending comments, bots, deleted authors, a missing viewer, and drops a thread without comments', async () => { + const gh = review(firstPage(page([ + threadNode('t1', { comments: page([ + commentNode('c1', { author: { __typename: 'Bot', login: 'ci', avatarUrl: null } }), + commentNode('c2', { state: 'PENDING', author: null, url: null }), + ]) }), + threadNode('empty', { comments: page([]) }, { diffHunk: '' }), + ]), head, null)) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ viewer: null, threads: [{ id: 't1', comments: [ + { id: 'c1', author: { login: 'ci', avatarUrl: null, bot: true }, pending: false }, + { id: 'c2', author: null, pending: true, url: null }, + ] }] }) + expect(result).toMatchObject({ threads: { length: 1 } }) + }) + + it('reads the following thread and comment pages, asking only for the threads left under maxReviewThreads', async () => { + const gh = review( + firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })], 'threads-1')), + { 'threads-1': nextPage(page([threadNode('t2')], 'threads-2')) }, + { 'comments-1': { data: { node: { comments: page([commentNode('c2')]) } } } }, + ) + + const result = await provider(gh.run, { maxReviewThreads: 2 }).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ truncated: true, threads: [ + { id: 't1', comments: [{ id: 'c1' }, { id: 'c2' }] }, { id: 't2' }, + ] }) + expect(gh.calls.slice(1)).toEqual([ + ['api', 'graphql', '--hostname', 'github.com', '-f', `query=${THREAD_COMMENTS_QUERY}`, '-f', 'id=t1', '-f', 'cursor=comments-1'], + ['api', 'graphql', '--hostname', 'github.com', '-f', `query=${REVIEW_THREADS_QUERY}`, '-f', 'id=pull-7', '-f', 'cursor=threads-1', '-F', 'first=1'], + ]) + expect(gh.calls[0]).toContain('first=2') + }) + + it('reports a head other than the requested commit as stale, on the first page or on a later one', async () => { + const moved = review(firstPage(page([threadNode('t1')]), pushed)) + await expect(provider(moved.run).readReview(pull(7), head, signal)) + .resolves.toEqual({ code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }) + + const later = review(firstPage(page([threadNode('t1')], 'threads-1')), { 'threads-1': nextPage(page([threadNode('t2')]), pushed) }) + await expect(provider(later.run).readReview(pull(7), head.toUpperCase(), signal)) + .resolves.toEqual({ code: 'stale', message: `the pull request head moved from ${head.toUpperCase()} to ${pushed}` }) + }) + + it('keeps the threads read before a page that passes maxOutputBytes and marks the result truncated', async () => { + const first = firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })], 'threads-1')) + const size = JSON.stringify(first).length + const comments = { 'comments-1': { data: { node: { comments: page([commentNode('c2')]) } } } } + const threads = { 'threads-1': nextPage(page([threadNode('t2')])) } + + const byComments = review(first, threads, comments) + await expect(provider(byComments.run, { maxOutputBytes: size + 10 }).readReview(pull(7), head, signal)) + .resolves.toEqual({ headSha: head, viewer: 'octo', threads: [], truncated: true }) + + const commentBytes = JSON.stringify(comments['comments-1']).length + const byThreads = review(first, threads, comments) + await expect(provider(byThreads.run, { maxOutputBytes: size + commentBytes + 10 }).readReview(pull(7), head, signal)) + .resolves.toMatchObject({ threads: [{ id: 't1' }], truncated: true }) + }) + + it.each([ + ['a thread page without a cursor', firstPage({ pageInfo: { hasNextPage: true, endCursor: null }, nodes: [] }), {}, {}, + 'GitHub review thread pagination did not advance'], + ['a thread page that repeats its cursor', firstPage(page([], 'threads-1')), { 'threads-1': nextPage(page([], 'threads-1')) }, {}, + 'GitHub review thread pagination did not advance'], + ['a comment page that repeats its cursor', firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })])), {}, + { 'comments-1': { data: { node: { comments: page([], 'comments-1') } } } }, 'GitHub review comment pagination did not advance'], + ['a comment page without a cursor', firstPage(page([threadNode('t1', { comments: { pageInfo: { hasNextPage: true, endCursor: null }, nodes: [] } })])), + {}, {}, 'GitHub review comment pagination did not advance'], + ['an unexpected first page', { data: { repository: { pullRequest: null } } }, {}, {}, 'unexpected GitHub response to the review thread read'], + ['an unexpected thread page', firstPage(page([], 'threads-1')), {}, {}, 'unexpected GitHub response to the review thread read'], + ['an unexpected comment page', firstPage(page([threadNode('t1', { comments: page([], 'comments-1') })])), {}, {}, + 'unexpected GitHub response to the review thread read'], + ] as const)('reports %s as unavailable', async (_label, first, later, comments, message) => { + const gh = review(first, later, comments) + + await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message }) + }) + + it('passes on the failure of any page read and of a response that is not JSON', async () => { + const missing = review(output('', 1, 'gh: Could not resolve to a PullRequest with the number of 7.')) + await expect(provider(missing.run).readReview(pull(7), head, signal)) + .resolves.toEqual({ code: 'not-found', message: 'gh: Could not resolve to a PullRequest with the number of 7.' }) + + const garbled = review(output('not json')) + await expect(provider(garbled.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message: 'unexpected GitHub response' }) + + const refused = review(output({ data: null, errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] })) + await expect(provider(refused.run).readReview(pull(7), head, signal)) + .resolves.toEqual({ code: 'unavailable', message: 'Resource not accessible by integration' }) + + const failing = fakeGh(args => args.includes(`query=${REVIEW_QUERY}`) + ? output(firstPage(page([threadNode('t1', { comments: page([], 'comments-1') })], 'threads-1'))) + : output('', 1, 'gh: Server Error (HTTP 502)')) + await expect(provider(failing.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message: 'gh: Server Error (HTTP 502)' }) + + const failingThreads = fakeGh(args => args.includes(`query=${REVIEW_QUERY}`) + ? output(firstPage(page([threadNode('t1')], 'threads-1'))) + : output('', 1, 'gh: Server Error (HTTP 502)')) + await expect(provider(failingThreads.run).readReview(pull(7), head, signal)) + .resolves.toEqual({ code: 'unavailable', message: 'gh: Server Error (HTTP 502)' }) + }) + + it('refuses an unconfigured host without starting gh and a head that is not a commit object name', async () => { + const gh = review(firstPage(page([]))) + const elsewhere = { ...demo, host: 'ghe.other.example' } + + await expect(provider(gh.run).readReview(pull(7, elsewhere), head, signal)) + .resolves.toEqual({ code: 'unavailable', message: 'forge-github: host "ghe.other.example" is not configured' }) + await expect(provider(gh.run).readReview(pull(7), 'main', signal)).rejects.toThrow('forge-github: invalid head commit "main"') + expect(gh.calls).toEqual([]) + }) +}) diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index f52f77e818..88068c3aee 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -7,7 +7,8 @@ import { changeRequestKey } from './keys.ts' import { nextPollDelay } from './schedule.ts' import type { ChangeRequestKey, ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, Config, ForgeCommitList, - ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository, ForgeReviewRequest, ForgeReviewResult, MergeRequest, + ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, + MergeRequest, } from './types.ts' export { changeRequestKey } from './keys.ts' @@ -44,7 +45,7 @@ interface Watch { /** Epoch milliseconds of the next poll; undefined while in flight or after a terminal state. */ dueAt: number | undefined failures: number - /** Head commit whose `stale` diff read last polled at once, so further stale reads at it wait for the regular poll. */ + /** Head commit whose `stale` diff or review read last polled at once, so further stale reads at it wait for the regular poll. */ staleHead: string | undefined } @@ -252,11 +253,24 @@ export class ForgeService extends Service { async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { const result = await this.invoke(this.registration(ref.repository.forge), signal, (provider, operation) => provider.diff(ref, headSha, operation)) - const watch = this.watches.get(changeRequestKey(ref)) - if (typeof result !== 'string' && result.code === 'stale' && watch !== undefined && watch.staleHead !== headSha) { - watch.staleHead = headSha - this.refresh(ref) - } + if (typeof result !== 'string') this.staleRead(ref, headSha, result) + return result + } + + /** + * Read the review threads of a change request at one head commit. A + * `stale` failure polls a watched change request again as {@link ForgeService.diff} does. + * @param ref - change request served by a registered provider. + * @param headSha - head commit the threads must be read at, usually the head of the displayed diff. + * @param signal - cancellation of the read. + * @returns the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ + async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { + const result = await this.invoke(this.registration(ref.repository.forge), signal, + (provider, operation) => provider.readReview?.(ref, headSha, operation) + ?? Promise.resolve({ code: 'unsupported', message: 'This provider does not support review reads' } as const)) + if ('code' in result) this.staleRead(ref, headSha, result) return result } @@ -384,6 +398,14 @@ export class ForgeService extends Service { return this.watches.get(changeRequestKey(ref))?.latest } + /** Poll a watched change request at once after the first `stale` read at `headSha`; later ones wait for the regular poll. */ + private staleRead(ref: ChangeRequestRef, headSha: string, failure: ForgeFailure): void { + const watch = this.watches.get(changeRequestKey(ref)) + if (failure.code !== 'stale' || watch === undefined || watch.staleHead === headSha) return + watch.staleHead = headSha + this.refresh(ref) + } + private provider(forge: string): ForgeProvider { return this.registration(forge).provider } diff --git a/packages/experimental/forge/src/types.ts b/packages/experimental/forge/src/types.ts index 18c8b02e6d..71bd7270e0 100644 --- a/packages/experimental/forge/src/types.ts +++ b/packages/experimental/forge/src/types.ts @@ -116,9 +116,9 @@ export interface ForgeFailure { * `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; * `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails * the same way; `stale`: the head is no longer the commit the read named, so reading again at the head of - * a later status can succeed; `unavailable`: any other failure. + * a later status can succeed; `unsupported`: the provider does not offer the read; `unavailable`: any other failure. */ - readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable' + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unsupported' | 'unavailable' /** Human-readable cause. */ readonly message: string } @@ -163,6 +163,86 @@ export type ForgeReviewRequest = { | { readonly kind: 'review'; readonly event: 'comment' | 'approve' | 'request-changes'; readonly body: string; readonly comments: readonly ForgeLineComment[] } ) +/** Opaque host identity of one review thread. */ +export type ForgeThreadId = Branded<'ForgeThreadId'> + +/** Opaque host identity of one review comment. */ +export type ForgeCommentId = Branded<'ForgeCommentId'> + +/** Consecutive lines of one file at one commit. */ +export interface ForgeAnchor { + /** Full object name of the commit whose file the lines number. */ + readonly commit: string + /** First line, inclusive. */ + readonly startLine: number + /** Last line, inclusive. */ + readonly endLine: number +} + +/** Account that wrote a review comment. */ +export interface ForgeCommentAuthor { + /** Login as the host spells it. */ + readonly login: string + /** Avatar image URL, or null when the host reports none. */ + readonly avatarUrl: string | null + /** Whether the account is an app or bot rather than a person. */ + readonly bot: boolean +} + +/** One comment of a review thread. */ +export interface ForgeReviewComment { + /** Host identity of the comment. */ + readonly id: ForgeCommentId + /** Author, or null when the account no longer exists. */ + readonly author: ForgeCommentAuthor | null + /** Markdown source as the author wrote it; it may contain raw HTML, which readers must not render as HTML. */ + readonly body: string + /** ISO 8601 time the comment was created. */ + readonly createdAt: string + /** Whether the comment belongs to the signed-in user's review that is not submitted yet, which only that user sees. */ + readonly pending: boolean + /** Web page of the comment, or null when the host reports none. */ + readonly url: string | null +} + +/** + * One review thread on a file of a change request. A line thread has two positions: `original` on the commit it was + * written on, and `current` on the head commit of the read. `current` is null once the host no longer places the + * thread on the head commit, as after later commits changed its lines. + */ +export interface ForgeReviewThread { + /** Host identity of the thread. */ + readonly id: ForgeThreadId + /** Repository-relative path the thread is on. */ + readonly path: string + /** Old (`L`) or new (`R`) side of the diff the lines number. */ + readonly side: 'L' | 'R' + /** Whether the thread is on lines or on the whole file. */ + readonly subject: 'line' | 'file' + /** Lines at the head commit of the read, whose `commit` is that head; null for an outdated or file thread. */ + readonly current: ForgeAnchor | null + /** Lines at the commit the thread was written on; null for a file thread. */ + readonly original: ForgeAnchor | null + /** Diff hunk the host recorded when the thread was written, ending at its last line; null when it recorded none. */ + readonly diffHunk: string | null + /** Whether the thread is marked resolved. */ + readonly resolved: boolean + /** Comments oldest first; the first starts the thread. */ + readonly comments: readonly ForgeReviewComment[] +} + +/** Review threads of a change request read at one head commit. */ +export interface ForgeReviewData { + /** Head commit the threads were read at; `current` positions number its files. */ + readonly headSha: string + /** Login of the signed-in user the provider read as, or null when the host does not name one. */ + readonly viewer: string | null + /** Threads in host order, at most the provider's limit. */ + readonly threads: readonly ForgeReviewThread[] + /** Whether the change request has more threads than `threads` holds. */ + readonly truncated: boolean +} + /** Result of a review write; unknown outcomes must be checked on the host before another submission. */ export type ForgeReviewResult = | { readonly ok: true; readonly url: string } @@ -220,6 +300,12 @@ export interface ForgeProvider { readonly forge: string /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** + * Read the review threads of a change request at head commit `headSha`. The threads are returned only when the + * read names `headSha` as the head; once the head is another commit, the read resolves to a `stale` failure. + * Omission declares that this provider cannot read reviews. + */ + readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ diff --git a/packages/experimental/forge/tests/registry.spec.ts b/packages/experimental/forge/tests/registry.spec.ts index 7a5d84ab7b..a87103be4e 100644 --- a/packages/experimental/forge/tests/registry.spec.ts +++ b/packages/experimental/forge/tests/registry.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import ForgeService, { changeRequestKey } from '../src/index.ts' +import type { ForgeReviewData } from '../src/types.ts' import { demo, FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' let ctx: Context | undefined @@ -125,6 +126,21 @@ describe('forge registry', () => { expect(compare).toHaveBeenCalledWith(ref(3), 'a'.repeat(40), 'b'.repeat(40), expect.any(AbortSignal)) }) + it('delegates review reads to the provider of their forge and reports a provider without them as unsupported', async () => { + ctx = await forgeContext() + const provider = new FakeProvider() + await provide(ctx, provider) + const signal = new AbortController().signal + + await expect(ctx.forge.readReview(ref(3), 'a'.repeat(40), signal)) + .resolves.toEqual({ code: 'unsupported', message: 'This provider does not support review reads' }) + const review: ForgeReviewData = { headSha: 'a'.repeat(40), viewer: 'octo', threads: [], truncated: false } + const readReview = vi.fn(async () => review) + Object.assign(provider, { readReview }) + await expect(ctx.forge.readReview(ref(3), 'a'.repeat(40), signal)).resolves.toBe(review) + expect(readReview).toHaveBeenCalledWith(ref(3), 'a'.repeat(40), expect.any(AbortSignal)) + }) + it('delegates change request actions to the provider of their forge and returns its outcome', async () => { ctx = await forgeContext() const provider = new FakeProvider() diff --git a/packages/experimental/forge/tests/watch.spec.ts b/packages/experimental/forge/tests/watch.spec.ts index 4537d82670..5903fb42fa 100644 --- a/packages/experimental/forge/tests/watch.spec.ts +++ b/packages/experimental/forge/tests/watch.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ForgeService, { changeRequestKey } from '../src/index.ts' -import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, ForgeFailure, ForgeRepository } from '../src/types.ts' +import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, ForgeFailure, ForgeRepository, ForgeReviewData } from '../src/types.ts' import { FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' const config = { activeIntervalMs: 1_000, idleIntervalMs: 5_000, maxBackoffMs: 6_000, fetchTimeoutMs: 500 } @@ -266,6 +266,29 @@ describe('forge watch', () => { expect(provider.fetches).toHaveLength(4) }) + it('polls at once after a review read finds a newer head, sharing the once-per-head bound with diff reads', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1))) + const readReview = vi.fn(async (): Promise => ({ code: 'unavailable', message: 'down' })) + Object.assign(provider, { readReview }) + const context = await setup(provider) + collect(context) + await vi.advanceTimersByTimeAsync(0) + const signal = new AbortController().signal + + await context.forge.readReview(ref(1), 'a'.repeat(40), signal) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(1) + readReview.mockResolvedValue({ code: 'stale', message: 'the head moved' }) + await expect(context.forge.readReview(ref(1), 'a'.repeat(40), signal)).resolves.toEqual({ code: 'stale', message: 'the head moved' }) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(2) + provider.readResult = { code: 'stale', message: 'the head moved' } + await context.forge.diff(ref(1), 'a'.repeat(40), signal) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(2) + }) + it('coalesces refreshes during a fetch into one immediate subsequent fetch', async () => { const provider = new FakeProvider() const release = held(provider, target => snapshot(target)) From a39bcaec124a20091a284554793a9f3c9a6eda69 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 13:58:57 +0800 Subject: [PATCH 35/81] feat(forge-panel): overlay GitHub review threads on the changes view Threads read at the shown head sit under the row of their last line, or after the collapsed lines that hold it; resolved threads fold into one row, file threads open their file, and outdated threads gather after the lines with the end of their recorded hunk and the file at the commit they were written on. Chosen commits place only new-side threads whose lines number the last chosen commit. The file tree and file headers count unresolved threads, and a thread list filters unresolved, outdated, resolved, and awaiting-my-reply threads by file and opens each at its place. A thread offers only Add to input, which quotes the whole thread, and View on GitHub. --- .../src/client/ChangedFiles.module.css | 2 + .../src/client/ChangedFiles.tsx | 6 +- .../src/client/ChangesTab.module.css | 58 +++ .../client-ui-forge/src/client/ChangesTab.tsx | 88 ++++- .../client-ui-forge/src/client/DiffFile.tsx | 58 ++- .../src/client/ReviewThreads.module.css | 219 ++++++++++++ .../src/client/ReviewThreads.tsx | 265 ++++++++++++++ .../src/client/ThreadList.module.css | 168 +++++++++ .../client-ui-forge/src/client/ThreadList.tsx | 163 +++++++++ .../client-ui-forge/src/client/locales.ts | 336 ++++++++++++++++++ .../client-ui-forge/src/client/mount.ts | 18 +- .../src/client/review-threads.ts | 171 +++++++++ .../src/client/thread-state.ts | 124 +++++++ .../tests/changes-tab.client.spec.tsx | 187 +++++++++- .../tests/mount.client.spec.ts | 17 +- .../tests/review-thread-cards.client.spec.tsx | 214 +++++++++++ .../tests/review-threads.client.spec.ts | 132 +++++++ .../tests/thread-fixtures.client.ts | 43 +++ 18 files changed, 2247 insertions(+), 22 deletions(-) create mode 100644 packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/ThreadList.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/ThreadList.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/review-threads.ts create mode 100644 packages/experimental/client-ui-forge/src/client/thread-state.ts create mode 100644 packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts diff --git a/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css b/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css index 4a57098d20..2006c2b546 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css @@ -5,6 +5,8 @@ .row { box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%; min-width: 0; min-height: 30px; padding: 4px 6px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: inherit; font: inherit; text-align: start; cursor: pointer; } .row svg { flex: none; } .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Unresolved host review threads of a file, in the thread count chip treatment of the file header. */ +.threads { flex: none; min-width: 16px; padding: 0 5px; box-sizing: border-box; border-radius: var(--dsw-radius-xs); background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); color: var(--dsw-alias-state-business-primary); font-size: 11px; line-height: 16px; text-align: center; } .selected { background: var(--dsw-alias-interactive-bg-hover); } .chevron { display: inline-flex; flex: 0 0 12px; } .collapsed { transform: rotate(-90deg); } diff --git a/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx b/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx index 995bae454d..eb24d62b51 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx @@ -25,6 +25,8 @@ export interface ChangedFilesProps extends PropsLocale { readonly jump: (path: string) => void /** A menu row participates in the shared menu's keyboard walk. */ readonly menu?: boolean + /** Unresolved host review threads by file path; files without any are absent. */ + readonly threadCounts?: ReadonlyMap } function Rows({ nodes, depth, props }: { @@ -32,10 +34,11 @@ function Rows({ nodes, depth, props }: { readonly depth: number readonly props: ChangedFilesProps }): ReactNode { - const { state, toggle, jump, menu, t } = props + const { state, toggle, jump, menu, threadCounts, t } = props return nodes.map((node) => { const directory = node.kind === 'directory' const expanded = directory && (state.query.trim() !== '' || state.collapsed[node.path] !== true) + const threads = node.kind === 'file' ? threadCounts?.get(node.path) ?? 0 : 0 const label = node.kind === 'file' && node.file.previousPath !== null ? t('diff.renamedPath', { from: node.file.previousPath, path: node.path }) : node.path return
@@ -54,6 +57,7 @@ function Rows({ nodes, depth, props }: { {directory ? : } {node.name} + {threads > 0 && {threads}} {node.kind === 'file' && } diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index f664c4b2fa..23452bbc00 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -333,6 +333,7 @@ } .body { + position: relative; display: flex; flex: 1; min-height: 0; @@ -813,3 +814,60 @@ .code[data-single-side]:not([data-wrap]) .line { grid-template-columns: var(--forge-number-width) 1.4em max-content; } .reviewFile { padding: 12px 0; } .reviewFile > h3 { margin: 0 10px; font-size: 12px; font-weight: 500; overflow-wrap: anywhere; } + +/* The unresolved thread count of a file opens the thread list at the file. */ +.threadCount { + flex: none; + height: 20px; + padding: 0 6px; + border: 0; + border-radius: var(--dsw-radius-xs); + background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); + color: var(--dsw-alias-state-business-primary); + font: inherit; + font-size: 11px; + line-height: 20px; + white-space: nowrap; + cursor: pointer; +} + +.threadCount:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 1px; +} + +@media (hover: hover) { + .threadCount:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 20%, transparent); } +} + +/* Opens the thread list; pressed while the list shows. */ +.threadsToggle { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 8px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + white-space: nowrap; + cursor: pointer; +} + +.threadsToggle[aria-pressed='true'] { background: var(--dsw-alias-interactive-bg-hover); } + +@media (hover: hover) { + .threadsToggle:hover { background: var(--dsw-alias-interactive-bg-hover); } +} + +.threadsTotal { + padding: 0 5px; + border-radius: var(--dsw-radius-xs); + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; +} diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index f57bb937bb..5763304362 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -27,7 +27,9 @@ import type { ForgeBaseBranch, ForgeBaseBranches, ForgeChangeRequestDiff, ForgeCommitRange, ForgeDiffFile, ForgeLocalCommits, ForgeLocalDiff, ForgeLocalScope, ForgePanelChangeRequest, ForgePanelState, } from '../types.ts' -import type { ForgeCommitList, ForgeReviewRequest, ForgeReviewResult } from '@deepseek-ai/dsh-experimental-forge/types' +import type { + ForgeCommitList, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, ForgeReviewThread, +} from '@deepseek-ai/dsh-experimental-forge/types' import { sameReviewSource, type ReviewQueue } from './review-queue.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' import type { ReviewActions } from './ReviewComposer.tsx' @@ -47,6 +49,10 @@ import { drawnLines, matchesHunks, textLines } from './diff-rows.ts' import { ChangeRequestGlyph } from './glyphs.tsx' import type { ForgePanelKey, NS } from './locales.ts' import { RefreshGlyph } from './RefreshGlyph.tsx' +import type { FileThreads, ThreadActions, ThreadFocus } from './ReviewThreads.tsx' +import type { ThreadFilter } from './review-threads.ts' +import { usePlacedThreads, useThreadRead, type PlacedThread } from './thread-state.ts' +import { ThreadList } from './ThreadList.tsx' import type { PanelSnapshot } from './source.ts' import { numberOf, openChangeRequestOf, TONE_LABEL, toneOf } from './status.ts' import css from './ChangesTab.module.css' @@ -86,6 +92,10 @@ export interface ChangesTabInjected { readonly viewedFiles: ViewedFiles /** Read one file of a change request at a commit. */ readonly changeRequestText: (url: string, commit: string, path: string) => Promise> + /** Read the review threads of a change request at the head of its latest status; `signal` aborts the read. */ + readonly changeRequestReview: (url: string, headSha: string, signal: AbortSignal) => Promise> + /** Quote a whole review thread of the change request at `url` in the main input without submitting it. */ + readonly addThreadToInput: (thread: ForgeReviewThread, url: string, range: string | null) => boolean /** Open a URL outside the app. */ readonly openLink: (url: string) => void /** Copy a path to the clipboard and report the outcome. */ @@ -423,8 +433,8 @@ function OrphanReviews({ path, actions, t }: PropsLocale & { */ export function ChangesTab({ useTabInfo, sessionId, useStore, actions, usePanel, reconnect, localDiff, diffBases, localText, localCommits, changeRequestDiff, - changeRequestText, changeRequestCommits, viewedFiles, - openLink, copyPath, createChangeRequest, addReviewToInput, reviewQueue, submitReview, report, t, + changeRequestText, changeRequestCommits, changeRequestReview, viewedFiles, + openLink, copyPath, createChangeRequest, addReviewToInput, addThreadToInput, reviewQueue, submitReview, report, t, }: ChangesTabProps): ReactNode { const { tab } = useTabInfo() const { navigation, signal } = tab @@ -560,6 +570,22 @@ export function ChangesTab({ return () => { controller.abort() } }, [tab.visible, read, refused, waiting, source, localScope, changeCommits, dataKey, localDiff, changeRequestDiff]) const data = loaded !== null && loaded.key === dataKey ? loaded.data : null + // Threads show only while they were read at the head of the latest status and, for the whole change request, at + // the head the shown diff was read at, so their positions number the shown files. + const prHead = entry?.snapshot?.headSha ?? null + const prAuthor = entry?.snapshot?.author ?? null + const threadRead = useThreadRead({ visible: tab.visible, url: source.kind === 'change' ? source.url : null, head: prHead, + updatedAt: entry?.snapshot?.updatedAt ?? null, statusAt, attempt, read: changeRequestReview }) + const review = data?.kind === 'change' && threadRead.review !== null && threadRead.review.headSha === prHead + && (data.commits !== null || data.headSha === prHead) ? threadRead.review : null + const placed = usePlacedThreads(review, data?.kind === 'change' ? { head: prHead ?? '', end: data.headSha, commits: data.commits } : null) + const [threadList, setThreadList] = useState<{ + readonly open: boolean + readonly filter: ThreadFilter | null + /** File whose group the list scrolls to, or null. */ + readonly file: string | null + }>({ open: false, filter: 'unresolved', file: null }) + const [focus, setFocus] = useState(null) const allFiles = useMemo(() => filesInTreeOrder(changedFileTree(data?.files ?? [])), [data]) const files = useMemo(() => allFiles.filter(file => shownBy(file, lens, hideTests)), [allFiles, lens, hideTests]) const lensCounts = useMemo(() => Object.fromEntries(LENSES.map(item => [item, @@ -594,10 +620,16 @@ export function ChangesTab({ if (hideTests && hidden.layer === 'test') actions.toggledTests(tab.id) return } - sections.current.get(wanted)?.scrollIntoView({ block: 'start' }) + const section = sections.current.get(wanted) + section?.scrollIntoView({ block: 'start' }) + // A thread the reader opened from the thread list centers inside its file, which the jump expanded and revealed. + /* v8 ignore next 2 -- a focused thread's file is drawn and its card unfolded, so both are found. */ + const card = focus === null ? undefined : [...section?.querySelectorAll('[data-forge-thread]') ?? []] + .find(element => element.dataset.forgeThread === focus.id) + card?.scrollIntoView({ block: 'center' }) if (scrollport.current !== null) jumpPosition.current = { data, top: scrollport.current.scrollTop } setWanted(null) - }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id]) + }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id, focus]) const refresh = (): void => { setAttempt(value => value + 1) } const root = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.root : null // A chosen base branch that is gone or shares no history with HEAD offers the automatic fork point instead. @@ -676,6 +708,32 @@ export function ChangesTab({ .filter(path => !data?.files.some(file => file.path === path)) const orphanRecords = absentPaths.map(path => ) + const threadActions: ThreadActions | null = review === null || data?.kind !== 'change' ? null : { + viewer: review.viewer, + author: prAuthor, + addToInput: (thread, range) => addThreadToInput(thread, data.url, range), + openLink, + readText: (commit, path) => changeRequestText(data.url, commit, path), + inputFailed: () => { report('thread.inputFailed') }, + focused: focus, + } + const threadsOf = (path: string): FileThreads | undefined => { + const group = placed.files.get(path) + return threadActions === null || group === undefined ? undefined + : { ...group, actions: threadActions, openList: () => { setThreadList(value => ({ ...value, open: true, file: path })) } } + } + /** Show a thread from the list: all commits for a thread the chosen ones do not hold, then its file, unfolded and centered. */ + const openThread = ({ thread, place }: PlacedThread): void => { + const url = thread.comments[0]?.url ?? null + if (!allFiles.some(file => file.path === thread.path)) { + if (url !== null) openLink(url) + return + } + if (place.kind === 'elsewhere') actions.committed(tab.id, null) + setFocus(value => ({ id: thread.id, revision: (value?.revision ?? 0) + 1 })) + setRevealed(value => new Set([...value, thread.path])) + jump(thread.path) + } const jump = (path: string): void => { setTreeState(value => ({ ...value, selected: path })) setChosen(value => ({ ...value, [path]: true })) @@ -820,6 +878,7 @@ export function ChangesTab({ badge={badgeOf(file, data, t)} tooltip={pathTooltipOf(file, t)} actions={fileActions(data, file)} + threads={threadsOf(path)} viewed={viewedStateOf(marks, path, file.digest)} onViewed={(viewed) => { markViewed(data, file, viewed) }} sectionRef={(element) => { @@ -839,6 +898,7 @@ export function ChangesTab({ filter: (query) => { setTreeState(value => ({ ...value, query })) }, toggle: (path) => { setTreeState(value => ({ ...value, collapsed: { ...value.collapsed, [path]: value.collapsed[path] !== true } })) }, jump, + ...review === null ? {} : { threadCounts: placed.unresolved }, } // The toolbar collapses every file while any is expanded, as the files on screen show, and expands every file otherwise. const anyExpanded = files.some(isExpanded) @@ -887,6 +947,13 @@ export function ChangesTab({
) diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index ad46c66132..0e7c561125 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -18,6 +18,8 @@ import { segmentsOf, splitRows, type DiffRow, type SplitCell, type SplitRow } fr import { codeSideAt, readCodeSelection } from './code-selection.ts' import { reviewMatches, selectReviewLines, type ReviewSelection } from './review.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' +import { OutdatedThreads, ThreadCard, type FileThreads, type LineThread, type ThreadActions } from './ReviewThreads.tsx' +import { gapOf } from './review-threads.ts' import type { FileReviewState } from './review-queue.ts' import { ReviewComposer, type ReviewActions } from './ReviewComposer.tsx' import reviewCss from './ReviewComposer.module.css' @@ -77,6 +79,8 @@ export interface DiffFileProps { readonly viewed: ViewedState /** Mark the file viewed, which also collapses it, or clear the mark. */ readonly onViewed: (viewed: boolean) => void + /** Review threads of the file from the code host, or undefined while none are read. */ + readonly threads?: FileThreads | undefined /** Receives the section element, which scrolling to the file targets. */ readonly sectionRef: (element: HTMLElement | null) => void /** Forge panel copy. */ @@ -144,8 +148,8 @@ function pairKind(pair: SplitRow): 'context' | 'change' { } /** The hunks of one file with their collapsed and expanded unchanged lines. */ -function Lines({ file, text, open, onExpand, split, wrap, review, visible, comments, t }: Pick & { readonly comments: FileReviewState }): ReactNode { +function Lines({ file, text, open, onExpand, split, wrap, review, visible, comments, threads, t }: Pick & { readonly comments: FileReviewState }): ReactNode { const lines = typeof text === 'object' ? text : undefined const columns = split && file.additions > 0 && file.deletions > 0 const singleSide = file.status === 'added' ? 'R' : file.status === 'deleted' ? 'L' : null @@ -313,6 +317,17 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme remaining.delete(thread) } } + // A host thread follows the row of its last line, or the collapsed unchanged lines that hold it. + const unplaced = new Set(threads === undefined ? [] : threads.lines.map(item => ({ ...item, actions: threads.actions }))) + const threadCard = (item: LineThread & { readonly actions: ThreadActions }, columns: boolean): ReactNode => + + const appendThreads = (old: number | null, next: number | null): void => { + for (const item of unplaced) { + if ((item.place.side === 'L' ? old : next) !== item.place.endLine) continue + rows.push(threadCard(item, columns)) + unplaced.delete(item) + } + } const endsAt = (old: number | null, next: number | null): boolean => selection !== null && (selection.comment.side === 'L' ? old : next) === selection.comment.endLine for (const [at, segment] of prepared.entries()) { @@ -322,6 +337,11 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme {segment.count === null ? t('diff.unchangedRest') : t('diff.unchanged', { count: segment.count.toLocaleString('en-US') })} ) + for (const item of unplaced) { + if (gapOf(file, item.place.side, item.place.endLine) !== segment.index) continue + rows.push(threadCard(item, columns)) + unplaced.delete(item) + } } else if (columns) { for (const [index, pair] of splitRows(segment.rows).entries()) { rows.push(
) } + for (const item of unplaced) rows.push(threadCard(item, false)) if (!inserted) rows.push(editor) return
{rows}
} -function Body(props: DiffFileProps): ReactNode { - const { file, large, revealed, onReveal, review, t } = props +/** The file's lines or the note that replaces them, with its saved comments; host threads on lines follow when no lines show. */ +function Content(props: DiffFileProps): ReactNode { + const { file, large, revealed, onReveal, review, threads, t } = props const comments = useFileReview(review, file.path) const records = review === undefined ? null : comments.threads.map(thread => ) - if (file.binary) return <>

{t('diff.binary')}

{records} - if (file.oversized) return <>

{t('diff.fileTooLarge')}

{records} - if (file.hunks.length === 0) return records + const lineThreads = threads?.lines.map(item => ) + if (file.binary) return <>

{t('diff.binary')}

{records}{lineThreads} + if (file.oversized) return <>

{t('diff.fileTooLarge')}

{records}{lineThreads} + if (file.hunks.length === 0) return <>{records}{lineThreads} if (large && !revealed) { // A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines. return ( <>{records} + {records}{lineThreads} ) } return } +/** Host threads on the whole file, the lines, then the host threads that later commits made outdated. */ +function Body(props: DiffFileProps): ReactNode { + const { threads, t } = props + return <> + {threads?.file.map(thread => )} + + {threads !== undefined && } + +} + /** File actions live only while their owning tab is visible. */ function FileMenu({ name, actions, t }: { readonly name: string @@ -443,7 +479,7 @@ function FileMenu({ name, actions, t }: { * @returns the file section. */ export function DiffFile(props: DiffFileProps): ReactNode { - const { file, expanded, onToggle, badge, tooltip, actions, viewed, onViewed, sectionRef, t } = props + const { file, expanded, onToggle, badge, tooltip, actions, viewed, onViewed, threads, sectionRef, t } = props const name = file.path.slice(file.path.lastIndexOf('/') + 1) return (
{viewed === 'changed' && {t('viewed.changed')}} + {threads !== undefined && threads.unresolved > 0 && ( + + )} {badge !== '' && {badge}} {!file.binary && } diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css new file mode 100644 index 0000000000..00ed916016 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css @@ -0,0 +1,219 @@ +/* Review thread cards share the inline review form's card material; comment rows follow the conversation's 13px detail size. */ +.card, +.folded { + box-sizing: border-box; + width: calc(100% - 20px); + max-width: 720px; + margin: 10px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); + white-space: normal; +} + +.card { padding: 10px 12px; } + +.card[data-split][data-side="R"], +.folded[data-split][data-side="R"] { + margin-inline-start: clamp(10px, calc(100% - 740px), calc(50% + 10px)); +} + +.card[data-resolved] { background: var(--dsw-alias-bg-layer-2); } + +.folded { + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 4px 12px; + color: var(--dsw-alias-label-secondary); +} + +.folded > svg { flex: none; color: var(--dsw-alias-state-success-primary); } + +.foldedText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.place { + min-width: 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + overflow-wrap: anywhere; +} + +.spacer { flex: 1; } + +/* Text actions inside a card: no fill, accent ink, the same height as the tags beside them. */ +.link { + display: inline-flex; + flex: none; + align-items: center; + gap: 2px; + padding: 0; + border: 0; + background: transparent; + color: var(--dsw-alias-state-business-primary); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.link:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; } + +.link:focus-visible { + border-radius: var(--dsw-radius-xs); + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 2px; +} + +@media (hover: hover) { + .link:not(:disabled):hover { text-decoration: underline; } +} + +.turned { transform: rotate(-90deg); } + +.comments { + display: flex; + flex-direction: column; + gap: 12px; +} + +.comment { + display: flex; + gap: 8px; + min-width: 0; +} + +.avatar { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 50%; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + font-weight: 500; + line-height: 1; + user-select: none; +} + +.avatar img { + display: block; + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.commentMain { + flex: 1; + min-width: 0; +} + +.commentHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 20px; +} + +.login { font-weight: 500; overflow-wrap: anywhere; } + +.time { color: var(--dsw-alias-label-tertiary); font-size: 12px; } + +.body { + min-width: 0; + margin-top: 2px; + overflow-wrap: anywhere; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 10px -8px -4px; +} + +/* The end of the recorded hunk, drawn like the diff rows it came from. */ +.excerpt, +.original { + margin-bottom: 10px; + overflow-x: auto; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: var(--dsw-radius-sm); + font: var(--dsw-font-markdown-code-block); +} + +.excerptLine, +.originalLine { + min-height: 20px; + padding: 0 10px; + line-height: 20px; + white-space: pre; +} + +.excerptHeader { color: var(--dsw-alias-label-tertiary); } + +.added { background: var(--dsw-alias-file-diff-added-bg); } + +.deleted { background: var(--dsw-alias-file-diff-deleted-bg); } + +.original { + max-height: 320px; + overflow: auto; +} + +.originalLine { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 12px; +} + +.originalNumber { + min-width: 3ch; + color: var(--dsw-alias-label-tertiary); + text-align: right; + user-select: none; +} + +.marked { background: var(--dsw-alias-interactive-bg-hover); } + +.originalNote { + padding: 8px 10px; + color: var(--dsw-alias-label-secondary); + font-family: Inter, var(--dsw-font-family); +} + +/* Outdated threads of one file, after its lines. */ +.outdated { + border-top: 0.5px solid var(--dsw-alias-border-l3); + padding-bottom: 2px; +} + +.outdatedHeader { + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 12px; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); +} diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx new file mode 100644 index 0000000000..2ba58c24c3 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx @@ -0,0 +1,265 @@ +/** + * Read-only review threads of a change request inside the changes view: a thread card at its lines or at the top of + * its file, a resolved thread folded into one row, and the outdated threads of a file in a section after its lines. + */ + +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { + Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconRightUpOutlineRegular, MarkdownText, Tag, type MarkdownLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ForgeReviewComment, ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { formatAgo } from './format.ts' +import { anchorRange, hunkExcerpt, type ThreadPlace } from './review-threads.ts' +import type { NS } from './locales.ts' +import css from './ReviewThreads.module.css' + +type Translate = PropsLocale['t'] + +/** Reads, actions, and identities every thread card of one changes view shares. */ +export interface ThreadActions { + /** Login of the signed-in user, or null when the host names none. */ + readonly viewer: string | null + /** Login of the change request's author, or null when the host hides it. */ + readonly author: string | null + /** Quote the whole thread in the main input without sending it; false when the input refused it. */ + readonly addToInput: (thread: ForgeReviewThread, range: string | null) => boolean + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Read a file of the change request's repository at a commit. */ + readonly readText: (commit: string, path: string) => Promise> + /** Report that the input refused a thread. */ + readonly inputFailed: () => void + /** The latest jump to a thread, which unfolds that thread and its section until the reader folds them again; null before any. */ + readonly focused: ThreadFocus | null +} + +/** One jump to a thread; each jump has its own revision, so a jump to the same thread unfolds it again. */ +export interface ThreadFocus { + readonly id: ForgeThreadId + readonly revision: number +} + +/** A host thread shown on lines of one side. */ +export interface LineThread { + readonly thread: ForgeReviewThread + readonly place: Extract +} + +/** Host threads of one file and how its section reaches them. */ +export interface FileThreads { + /** Threads on lines, which follow the row of their last line. */ + readonly lines: readonly LineThread[] + /** Threads on the whole file, shown above its lines. */ + readonly file: readonly ForgeReviewThread[] + /** Threads no longer on the head's lines, shown after them. */ + readonly outdated: readonly ForgeReviewThread[] + /** Unresolved threads of the file in every place, including threads the chosen commits do not show. */ + readonly unresolved: number + /** Reads, actions, and identities shared by the cards. */ + readonly actions: ThreadActions + /** Open the thread list at this file. */ + readonly openList: () => void +} + +/** The commit a thread was written on, abbreviated, or null for a file thread. */ +function writtenOn(thread: ForgeReviewThread): string | null { + return thread.original === null ? null : thread.original.commit.slice(0, 7) +} + +/** + * Build the Markdown chrome copy of comment bodies. + * @param t - forge panel copy. + * @returns labels for code fences and footnotes. + */ +export function useMarkdownLabels(t: Translate): MarkdownLabels { + return useMemo(() => ({ + code: { copyLabel: t('thread.copy'), copiedLabel: t('thread.copied'), + toolbarLabels: { codeLabel: t('thread.codeBlock'), wrapLabel: t('thread.wrap'), unwrapLabel: t('thread.unwrap') } }, + footnotes: t('thread.footnotes'), + }), [t]) +} + +/** + * Render an author's avatar, or the first letter of the login when the image is missing or fails. + * @param props - the comment's author and the avatar size. + * @returns the round avatar. + */ +export function Avatar({ author, size = 20 }: { readonly author: ForgeReviewComment['author']; readonly size?: number }): ReactNode { + const [failed, setFailed] = useState(false) + const login = author?.login ?? '?' + return +} + +function Comment({ comment, actions, labels, now, t }: { + readonly comment: ForgeReviewComment + readonly actions: ThreadActions + readonly labels: MarkdownLabels + readonly now: number + readonly t: Translate +}): ReactNode { + const login = comment.author?.login ?? null + return
+ +
+
+ {login ?? t('thread.deletedAuthor')} + {formatAgo(comment.createdAt, now, t) ?? comment.createdAt} + {login !== null && login === actions.author && {t('thread.author')}} + {comment.author?.bot === true && {t('thread.bot')}} + {comment.pending && {t('review.pending')}} +
+ {/* Raw HTML renders nothing, as GitHub hides comments and unknown tags, and never enters the DOM. */} +
+
+
+} + +/** + * Render one thread: its place, every comment, and the read-only actions. A resolved thread folds into one row + * until the reader unfolds it or jumps to it. + * @param props - the thread, where it shows, the shared actions, the layout, and copy. + * @returns the thread card or its folded row. + */ +export function ThreadCard({ thread, place, actions, split = false, t }: PropsLocale & { + readonly thread: ForgeReviewThread + readonly place: ThreadPlace + readonly actions: ThreadActions + /** Whether the card sits under the side of a side-by-side row that its lines number. */ + readonly split?: boolean +}): ReactNode { + const [open, setOpen] = useState(false) + // The jump the reader folded the thread after, which no longer unfolds it. + const [foldedAt, setFoldedAt] = useState(null) + const labels = useMarkdownLabels(t) + const now = Date.now() + const focused = actions.focused?.id === thread.id && actions.focused.revision !== foldedAt + const unfolded = open || !thread.resolved || focused + const side = place.kind === 'line' ? place.side : thread.side + const range = place.kind === 'line' ? anchorRange(place.side, place) : null + const commit = writtenOn(thread) + const first = thread.comments[0] + const url = first?.url ?? null + if (!unfolded) { + return
+ + {t('thread.resolvedRow', { author: first?.author?.login ?? t('thread.deletedAuthor') })} + +
+ } + const original = place.kind === 'outdated' && thread.original !== null ? thread.original : null + return
+
+ + {original !== null ? t('thread.originally', { range: anchorRange(thread.side, original) }) + : range ?? t('thread.fileLevel')} + {commit !== null && <> · {t('thread.onCommit', { commit })}} + + {place.kind === 'outdated' && {t('thread.outdatedTag')}} + {thread.resolved && {t('thread.filter.resolved')}} + + {thread.resolved && } + {original !== null && thread.side === 'R' && } +
+ {place.kind === 'outdated' && thread.diffHunk !== null && original !== null + && } +
+ {thread.comments.map(comment => )} +
+
+ + {url !== null && } +
+
+} + +/** The end of a recorded diff hunk: the lines a thread was written on and a few before them. */ +function Excerpt({ hunk, side, lines }: { readonly hunk: string; readonly side: 'L' | 'R'; readonly lines: number }): ReactNode { + const excerpt = hunkExcerpt(hunk, side, lines) + return
+ {excerpt.header !== null &&
{excerpt.header}
} + {excerpt.lines.map((line, index) =>
{line}
)} +
+} + +/** + * Show the whole file at the commit a thread was written on, with the thread's lines marked, under the thread header. + * Only a new-side thread offers it: its lines number that commit's file. + */ +function OriginalFile({ thread, commit, actions, t }: PropsLocale & { + readonly thread: ForgeReviewThread + readonly commit: string + readonly actions: ThreadActions +}): ReactNode { + const [state, setState] = useState<{ readonly lines: readonly string[] } | 'closed' | 'loading' | { readonly failed: string }>('closed') + const marked = useRef(null) + useEffect(() => { marked.current?.scrollIntoView({ block: 'center' }) }, [state]) + const toggle = (): void => { + if (state !== 'closed' && state !== 'loading') { setState('closed'); return } + setState('loading') + void actions.readText(commit, thread.path).then((result) => { + setState(result.ok ? { lines: result.value.split('\n') } : { failed: result.error.message }) + }) + } + const open = state !== 'closed' + const range = thread.original + return <> + + {open &&
+ {state === 'loading' ?
{t('thread.originalLoading')}
+ : 'failed' in state ?
{t('thread.originalFailed', { message: state.failed })}
+ : state.lines.map((line, index) => { + const number = index + 1 + const inside = range !== null && number >= range.startLine && number <= range.endLine + return
+ {number}{line} +
+ })} +
} + +} + +/** + * Render the outdated threads of a file after its lines, as one section the reader can fold. + * @param props - the threads, the shared actions, and copy. + * @returns the section, or nothing without outdated threads. + */ +export function OutdatedThreads({ threads, actions, t }: PropsLocale & { + readonly threads: readonly ForgeReviewThread[] + readonly actions: ThreadActions +}): ReactNode { + // The section is folded after the jump with this revision, or -1 before any jump, or null while unfolded. + const [folded, setFolded] = useState(null) + if (threads.length === 0) return null + const jump = actions.focused + const open = folded === null || (jump !== null && jump.revision !== folded && threads.some(thread => thread.id === jump.id)) + return
+
+ {t('thread.outdatedSection', { count: threads.length })} + + +
+ {open && threads.map(thread => )} +
+} diff --git a/packages/experimental/client-ui-forge/src/client/ThreadList.module.css b/packages/experimental/client-ui-forge/src/client/ThreadList.module.css new file mode 100644 index 0000000000..c38bf4b549 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ThreadList.module.css @@ -0,0 +1,168 @@ +/* The thread list docks at the end of the changes body on wide panes and floats over the files on narrow ones. */ +.pane { + display: flex; + flex: none; + flex-direction: column; + box-sizing: border-box; + width: 320px; + min-height: 0; + border-inline-start: 0.5px solid var(--dsw-alias-border-l3); + background: var(--forge-changes-bg, var(--dsw-alias-bg-base)); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); +} + +@container forge-changes (max-width: 759px) { + .pane { + position: absolute; + z-index: 3; + inset-block: 0; + inset-inline-end: 0; + width: min(320px, 100%); + box-shadow: var(--dsw-shadow-l2, 0 4px 16px rgb(0 0 0 / 12%)); + } +} + +.header { + display: flex; + align-items: center; + gap: 6px; + min-height: 40px; + padding: 0 6px 0 12px; +} + +.title { font-weight: 500; } + +.total { + padding: 0 6px; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-alias-card-fill); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; +} + +.spacer { flex: 1; } + +.close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +@media (hover: hover) { + .close:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 12px 10px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + +.rows { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 6px; +} + +.group + .group { margin-top: 6px; } + +.path { + margin: 0; + padding: 8px 6px 4px; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item { + display: flex; + gap: 8px; + box-sizing: border-box; + width: 100%; + padding: 8px 6px; + border: 0; + border-radius: var(--dsw-radius-md); + background: transparent; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; +} + +@media (hover: hover) { + .item:hover { background: var(--dsw-alias-interactive-bg-hover); } +} + +.item:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -1px; +} + +.itemMain { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.meta { color: var(--dsw-alias-label-tertiary); font-size: 12px; } + +.excerpt { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow-wrap: anywhere; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.tags:empty { display: none; } + +.note { + padding: 24px 12px; + color: var(--dsw-alias-label-secondary); + text-align: center; +} + +.footer { + padding: 10px 12px; + border-top: 0.5px solid var(--dsw-alias-border-l3); + color: var(--dsw-alias-label-secondary); + font-size: 12px; +} + +.link { + padding: 0; + border: 0; + background: transparent; + color: var(--dsw-alias-state-business-primary); + font: inherit; + cursor: pointer; +} + +@media (hover: hover) { + .link:hover { text-decoration: underline; } +} diff --git a/packages/experimental/client-ui-forge/src/client/ThreadList.tsx b/packages/experimental/client-ui-forge/src/client/ThreadList.tsx new file mode 100644 index 0000000000..66a968c788 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ThreadList.tsx @@ -0,0 +1,163 @@ +/** + * The thread list of a change request beside its changes: every thread grouped by file in file tree order, narrowed + * by one filter, each opening the place it shows at. + */ + +import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react' +import { IconCloseOutlineRegular, Pill, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { Avatar } from './ReviewThreads.tsx' +import { anchorRange, awaitsViewer, passesFilter, THREAD_FILTERS, type ThreadFilter, type ThreadPlace } from './review-threads.ts' +import type { NS } from './locales.ts' +import type { PlacedThread } from './thread-state.ts' +import css from './ThreadList.module.css' + +type Translate = PropsLocale['t'] + +/** Props of the thread list. */ +export interface ThreadListProps extends PropsLocale { + /** Every thread read, in host order, with where the current changes show it. */ + readonly entries: readonly PlacedThread[] + /** Changed file paths in file tree order; threads on other paths follow them. */ + readonly order: readonly string[] + /** Login of the signed-in user. */ + readonly viewer: string | null + /** Whether the host has more threads than were read. */ + readonly truncated: boolean + /** Host name the threads were read from, such as `github.com`. */ + readonly host: string + /** Web page of the change request. */ + readonly url: string + /** The active filter, or null for every thread. */ + readonly filter: ThreadFilter | null + /** Choose a filter, or null for every thread. */ + readonly choose: (filter: ThreadFilter | null) => void + /** File whose group the list scrolls to when it changes, or null. */ + readonly file: string | null + /** Open the place of a thread. */ + readonly open: (entry: PlacedThread) => void + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Close the list. */ + readonly close: () => void +} + +/** Sort key of a thread inside its file: file threads first, then by first line, outdated and elsewhere last. */ +function rank(place: ThreadPlace): number { + switch (place.kind) { + case 'file': return -1 + case 'line': return place.startLine + case 'outdated': + case 'elsewhere': return Number.MAX_SAFE_INTEGER + /* v8 ignore next -- closed ThreadPlace union exhaustiveness guard */ + default: return assertNever(place, 'ThreadPlace') + } +} + +function meta(thread: ForgeReviewThread, place: ThreadPlace, t: Translate): string { + const replies = thread.comments.length - 1 + const parts: string[] = [] + if (thread.subject === 'file') parts.push(t('thread.fileLevel')) + else if (place.kind === 'line') parts.push(anchorRange(place.side, place)) + else if (thread.original !== null) { + parts.push(t('thread.originally', { range: anchorRange(thread.side, thread.original) }), t('thread.onCommit', { commit: thread.original.commit.slice(0, 7) })) + } + if (replies > 0) parts.push(t('thread.replies', { count: replies })) + return parts.join(' · ') +} + +/** + * The first comment as one line of plain text: without raw HTML, strong, strikethrough, and code marks, quote or + * heading marks, and link targets. + */ +function plainExcerpt(body: string): string { + return body.replace(/<[^>]*>/g, ' ').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s*(?:#{1,6}|>)\s*/gm, '') + .replace(/\*\*|__|~~|`/g, '').replace(/\s+/g, ' ').trim() +} + +function Item({ entry, viewer, open, t }: { + readonly entry: PlacedThread + readonly viewer: string | null + readonly open: (entry: PlacedThread) => void + readonly t: Translate +}): ReactNode { + const { thread, place } = entry + const first = thread.comments[0] + return +} + +/** + * Render the thread list. + * @param props - threads, their places, the file order, the filter, and the list's actions. + * @returns the list pane. + */ +export function ThreadList(props: ThreadListProps): ReactNode { + const { entries, order, viewer, filter, t } = props + const threads = useMemo(() => entries.map(entry => entry.thread), [entries]) + const counts = useMemo(() => Object.fromEntries(THREAD_FILTERS.map(item => [item, + threads.filter(thread => passesFilter(thread, item, viewer)).length])) as Record, [threads, viewer]) + const groups = useMemo(() => { + const position = new Map(order.map((path, index) => [path, index])) + const byPath = new Map() + for (const entry of entries) { + if (!passesFilter(entry.thread, filter, viewer)) continue + const list = byPath.get(entry.thread.path) + if (list === undefined) byPath.set(entry.thread.path, [entry]) + else list.push(entry) + } + return [...byPath].sort(([a], [b]) => (position.get(a) ?? order.length) - (position.get(b) ?? order.length) || a.localeCompare(b)) + .map(([path, list]) => [path, list.toSorted((a, b) => rank(a.place) - rank(b.place))] as const) + }, [entries, order, filter, viewer]) + const rows = useRef(null) + useLayoutEffect(() => { + const container = rows.current + /* v8 ignore next -- the ref is attached before layout effects run. */ + if (props.file === null || container === null) return + for (const group of container.querySelectorAll('[data-forge-thread-group]')) { + if (group.dataset.forgeThreadGroup === props.file) group.scrollIntoView({ block: 'start' }) + } + }, [props.file]) + return +} diff --git a/packages/experimental/client-ui-forge/src/client/locales.ts b/packages/experimental/client-ui-forge/src/client/locales.ts index 81cb739802..96955093d2 100644 --- a/packages/experimental/client-ui-forge/src/client/locales.ts +++ b/packages/experimental/client-ui-forge/src/client/locales.ts @@ -308,6 +308,48 @@ export const zh = { 'toast.expandStale': '文件在读取变更后又有修改,刷新后再展开', 'toast.prompted': '已发送:{text}', 'toast.promptFailed': '无法发送消息', + 'thread.list': '讨论', + 'thread.listAria': '讨论列表,共 {count} 个', + 'thread.close': '关闭讨论列表', + 'thread.filterAria': '筛选讨论', + 'thread.filter.unresolved': '未解决', + 'thread.filter.outdated': '已过期', + 'thread.filter.resolved': '已解决', + 'thread.filter.mine': '需要我回复', + 'thread.none': '没有符合筛选条件的讨论', + 'thread.empty': '还没有评审讨论', + 'thread.total': '{host} 上共 {count} 个讨论', + 'thread.truncated': '讨论太多,只显示了前 {count} 个', + 'thread.openAll': '在 GitHub 中查看', + 'thread.replies': '{count} 条回复', + 'thread.fileLevel': '文件级讨论', + 'thread.onCommit': '在 {commit} 上', + 'thread.originally': '原在 {range}', + 'thread.outdatedTag': '已过期', + 'thread.elsewhereTag': '不在所选提交中', + 'thread.author': '作者', + 'thread.bot': 'bot', + 'thread.deletedAuthor': '已删除的账号', + 'thread.resolvedRow': '已解决的讨论 · {author}', + 'thread.expand': '展开', + 'thread.collapse': '收起', + 'thread.outdatedSection': '{count} 条讨论已过期:所在代码已被后续提交修改', + 'thread.viewOriginal': '查看当时的文件', + 'thread.hideOriginal': '收起当时的文件', + 'thread.originalLoading': '正在读取当时的文件…', + 'thread.originalFailed': '无法读取当时的文件:{message}', + 'thread.openOnHost': '在 GitHub 中查看', + 'thread.unresolvedCount': '{count} 条未解决', + 'thread.unresolvedAria': '{name} 有 {count} 条未解决的讨论,打开讨论列表', + 'thread.treeCount': '{count} 条未解决的讨论', + 'thread.loadFailed': '无法读取 GitHub 讨论:{message}', + 'thread.inputFailed': '输入框暂时无法添加这个讨论,请稍后重试', + 'thread.copy': '复制', + 'thread.copied': '复制成功', + 'thread.codeBlock': '代码块', + 'thread.wrap': '自动换行', + 'thread.unwrap': '取消自动换行', + 'thread.footnotes': '脚注', } as const /** Key of the forge panel dictionaries. */ @@ -618,6 +660,48 @@ export const en: Record = { 'toast.expandStale': 'The file changed after its changes were read; refresh before expanding', 'toast.prompted': 'Sent: {text}', 'toast.promptFailed': 'The message could not be sent', + 'thread.list': 'Threads', + 'thread.listAria': 'Review threads, {count} in total', + 'thread.close': 'Close the thread list', + 'thread.filterAria': 'Filter threads', + 'thread.filter.unresolved': 'Unresolved', + 'thread.filter.outdated': 'Outdated', + 'thread.filter.resolved': 'Resolved', + 'thread.filter.mine': 'Awaiting my reply', + 'thread.none': 'No threads match this filter', + 'thread.empty': 'No review threads yet', + 'thread.total': '{count} threads on {host}', + 'thread.truncated': 'Too many threads; showing the first {count}', + 'thread.openAll': 'View on GitHub', + 'thread.replies': '{count} replies', + 'thread.fileLevel': 'File thread', + 'thread.onCommit': 'on {commit}', + 'thread.originally': 'Originally {range}', + 'thread.outdatedTag': 'Outdated', + 'thread.elsewhereTag': 'Not in the chosen commits', + 'thread.author': 'Author', + 'thread.bot': 'bot', + 'thread.deletedAuthor': 'Deleted account', + 'thread.resolvedRow': 'Resolved thread · {author}', + 'thread.expand': 'Expand', + 'thread.collapse': 'Collapse', + 'thread.outdatedSection': '{count} outdated threads: later commits changed their code', + 'thread.viewOriginal': 'View file at that commit', + 'thread.hideOriginal': 'Hide file at that commit', + 'thread.originalLoading': 'Reading the file at that commit…', + 'thread.originalFailed': 'Could not read the file at that commit: {message}', + 'thread.openOnHost': 'View on GitHub', + 'thread.unresolvedCount': '{count} unresolved', + 'thread.unresolvedAria': '{name} has {count} unresolved threads; open the thread list', + 'thread.treeCount': '{count} unresolved threads', + 'thread.loadFailed': 'Could not read GitHub threads: {message}', + 'thread.inputFailed': 'The input could not take this thread; try again later', + 'thread.copy': 'Copy', + 'thread.copied': 'Copied', + 'thread.codeBlock': 'Code block', + 'thread.wrap': 'Wrap lines', + 'thread.unwrap': 'Do not wrap lines', + 'thread.footnotes': 'Footnotes', } declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -932,6 +1016,48 @@ export const ja: Record = { 'toast.expandStale': '変更の読み取り後にファイルが更新されています。更新してから展開してください', 'toast.prompted': '送信しました:{text}', 'toast.promptFailed': 'メッセージを送信できませんでした', + 'thread.list': 'スレッド', + 'thread.listAria': 'レビュースレッド(全 {count} 件)', + 'thread.close': 'スレッド一覧を閉じる', + 'thread.filterAria': 'スレッドを絞り込む', + 'thread.filter.unresolved': '未解決', + 'thread.filter.outdated': '古い', + 'thread.filter.resolved': '解決済み', + 'thread.filter.mine': '自分の返信待ち', + 'thread.none': 'この条件に一致するスレッドはありません', + 'thread.empty': 'レビュースレッドはまだありません', + 'thread.total': '{host} 上のスレッド {count} 件', + 'thread.truncated': 'スレッドが多いため、最初の {count} 件のみ表示しています', + 'thread.openAll': 'GitHub で表示', + 'thread.replies': '返信 {count} 件', + 'thread.fileLevel': 'ファイルへのスレッド', + 'thread.onCommit': '{commit} 上', + 'thread.originally': '元の位置 {range}', + 'thread.outdatedTag': '古い', + 'thread.elsewhereTag': '選択したコミットにない', + 'thread.author': '作成者', + 'thread.bot': 'bot', + 'thread.deletedAuthor': '削除されたアカウント', + 'thread.resolvedRow': '解決済みのスレッド · {author}', + 'thread.expand': '展開', + 'thread.collapse': '折りたたむ', + 'thread.outdatedSection': '古いスレッド {count} 件:後のコミットで該当コードが変更されました', + 'thread.viewOriginal': '当時のファイルを表示', + 'thread.hideOriginal': '当時のファイルを閉じる', + 'thread.originalLoading': '当時のファイルを読み込み中…', + 'thread.originalFailed': '当時のファイルを読み込めません:{message}', + 'thread.openOnHost': 'GitHub で表示', + 'thread.unresolvedCount': '未解決 {count} 件', + 'thread.unresolvedAria': '{name} に未解決のスレッドが {count} 件あります。スレッド一覧を開く', + 'thread.treeCount': '未解決のスレッド {count} 件', + 'thread.loadFailed': 'GitHub のスレッドを読み込めません:{message}', + 'thread.inputFailed': '入力欄にこのスレッドを追加できません。しばらくしてから再試行してください', + 'thread.copy': 'コピー', + 'thread.copied': 'コピーしました', + 'thread.codeBlock': 'コードブロック', + 'thread.wrap': '行を折り返す', + 'thread.unwrap': '行を折り返さない', + 'thread.footnotes': '脚注', } /** Spanish copy with the same keys. */ @@ -1239,6 +1365,48 @@ export const es: Record = { 'toast.expandStale': 'El archivo cambió después de leer sus cambios; actualiza antes de expandir', 'toast.prompted': 'Enviado: {text}', 'toast.promptFailed': 'No se pudo enviar el mensaje', + 'thread.list': 'Hilos', + 'thread.listAria': 'Hilos de revisión, {count} en total', + 'thread.close': 'Cerrar la lista de hilos', + 'thread.filterAria': 'Filtrar hilos', + 'thread.filter.unresolved': 'Sin resolver', + 'thread.filter.outdated': 'Desactualizados', + 'thread.filter.resolved': 'Resueltos', + 'thread.filter.mine': 'Esperan mi respuesta', + 'thread.none': 'Ningún hilo coincide con este filtro', + 'thread.empty': 'Aún no hay hilos de revisión', + 'thread.total': '{count} hilos en {host}', + 'thread.truncated': 'Hay demasiados hilos; se muestran los primeros {count}', + 'thread.openAll': 'Ver en GitHub', + 'thread.replies': '{count} respuestas', + 'thread.fileLevel': 'Hilo del archivo', + 'thread.onCommit': 'en {commit}', + 'thread.originally': 'Originalmente {range}', + 'thread.outdatedTag': 'Desactualizado', + 'thread.elsewhereTag': 'No está en los commits elegidos', + 'thread.author': 'Autor', + 'thread.bot': 'bot', + 'thread.deletedAuthor': 'Cuenta eliminada', + 'thread.resolvedRow': 'Hilo resuelto · {author}', + 'thread.expand': 'Expandir', + 'thread.collapse': 'Contraer', + 'thread.outdatedSection': '{count} hilos desactualizados: commits posteriores cambiaron su código', + 'thread.viewOriginal': 'Ver el archivo en ese commit', + 'thread.hideOriginal': 'Ocultar el archivo de ese commit', + 'thread.originalLoading': 'Leyendo el archivo de ese commit…', + 'thread.originalFailed': 'No se pudo leer el archivo de ese commit: {message}', + 'thread.openOnHost': 'Ver en GitHub', + 'thread.unresolvedCount': '{count} sin resolver', + 'thread.unresolvedAria': '{name} tiene {count} hilos sin resolver; abrir la lista de hilos', + 'thread.treeCount': '{count} hilos sin resolver', + 'thread.loadFailed': 'No se pudieron leer los hilos de GitHub: {message}', + 'thread.inputFailed': 'El campo de entrada no pudo recibir este hilo; inténtalo más tarde', + 'thread.copy': 'Copiar', + 'thread.copied': 'Copiado', + 'thread.codeBlock': 'Bloque de código', + 'thread.wrap': 'Ajustar líneas', + 'thread.unwrap': 'No ajustar líneas', + 'thread.footnotes': 'Notas a pie de página', } /** French copy with the same keys. */ @@ -1546,6 +1714,48 @@ export const fr: Record = { 'toast.expandStale': 'Le fichier a changé depuis la lecture des modifications ; actualisez avant de développer', 'toast.prompted': 'Envoyé : {text}', 'toast.promptFailed': 'Impossible d’envoyer le message', + 'thread.list': 'Fils', + 'thread.listAria': 'Fils de revue, {count} au total', + 'thread.close': 'Fermer la liste des fils', + 'thread.filterAria': 'Filtrer les fils', + 'thread.filter.unresolved': 'Non résolus', + 'thread.filter.outdated': 'Obsolètes', + 'thread.filter.resolved': 'Résolus', + 'thread.filter.mine': 'Attendent ma réponse', + 'thread.none': 'Aucun fil ne correspond à ce filtre', + 'thread.empty': 'Aucun fil de revue pour le moment', + 'thread.total': '{count} fils sur {host}', + 'thread.truncated': 'Trop de fils ; seuls les {count} premiers sont affichés', + 'thread.openAll': 'Voir sur GitHub', + 'thread.replies': '{count} réponses', + 'thread.fileLevel': 'Fil du fichier', + 'thread.onCommit': 'sur {commit}', + 'thread.originally': 'À l’origine {range}', + 'thread.outdatedTag': 'Obsolète', + 'thread.elsewhereTag': 'Hors des commits choisis', + 'thread.author': 'Auteur', + 'thread.bot': 'bot', + 'thread.deletedAuthor': 'Compte supprimé', + 'thread.resolvedRow': 'Fil résolu · {author}', + 'thread.expand': 'Développer', + 'thread.collapse': 'Réduire', + 'thread.outdatedSection': '{count} fils obsolètes : des commits ultérieurs ont modifié leur code', + 'thread.viewOriginal': 'Voir le fichier à ce commit', + 'thread.hideOriginal': 'Masquer le fichier à ce commit', + 'thread.originalLoading': 'Lecture du fichier à ce commit…', + 'thread.originalFailed': 'Impossible de lire le fichier à ce commit : {message}', + 'thread.openOnHost': 'Voir sur GitHub', + 'thread.unresolvedCount': '{count} non résolus', + 'thread.unresolvedAria': '{name} a {count} fils non résolus ; ouvrir la liste des fils', + 'thread.treeCount': '{count} fils non résolus', + 'thread.loadFailed': 'Impossible de lire les fils GitHub : {message}', + 'thread.inputFailed': 'Le champ de saisie n’a pas pu recevoir ce fil ; réessayez plus tard', + 'thread.copy': 'Copier', + 'thread.copied': 'Copié', + 'thread.codeBlock': 'Bloc de code', + 'thread.wrap': 'Activer le retour à la ligne', + 'thread.unwrap': 'Désactiver le retour à la ligne', + 'thread.footnotes': 'Notes de bas de page', } /** German copy with the same keys. */ @@ -1853,6 +2063,48 @@ export const de: Record = { 'toast.expandStale': 'Die Datei wurde nach dem Lesen der Änderungen verändert; vor dem Aufklappen aktualisieren', 'toast.prompted': 'Gesendet: {text}', 'toast.promptFailed': 'Nachricht konnte nicht gesendet werden', + 'thread.list': 'Threads', + 'thread.listAria': 'Review-Threads, insgesamt {count}', + 'thread.close': 'Threadliste schließen', + 'thread.filterAria': 'Threads filtern', + 'thread.filter.unresolved': 'Ungelöst', + 'thread.filter.outdated': 'Veraltet', + 'thread.filter.resolved': 'Gelöst', + 'thread.filter.mine': 'Warten auf meine Antwort', + 'thread.none': 'Keine Threads entsprechen diesem Filter', + 'thread.empty': 'Noch keine Review-Threads', + 'thread.total': '{count} Threads auf {host}', + 'thread.truncated': 'Zu viele Threads; die ersten {count} werden angezeigt', + 'thread.openAll': 'Auf GitHub ansehen', + 'thread.replies': '{count} Antworten', + 'thread.fileLevel': 'Thread zur Datei', + 'thread.onCommit': 'auf {commit}', + 'thread.originally': 'Ursprünglich {range}', + 'thread.outdatedTag': 'Veraltet', + 'thread.elsewhereTag': 'Nicht in den gewählten Commits', + 'thread.author': 'Autor', + 'thread.bot': 'Bot', + 'thread.deletedAuthor': 'Gelöschtes Konto', + 'thread.resolvedRow': 'Gelöster Thread · {author}', + 'thread.expand': 'Aufklappen', + 'thread.collapse': 'Zuklappen', + 'thread.outdatedSection': '{count} veraltete Threads: Spätere Commits haben ihren Code geändert', + 'thread.viewOriginal': 'Datei in diesem Commit ansehen', + 'thread.hideOriginal': 'Datei in diesem Commit ausblenden', + 'thread.originalLoading': 'Datei in diesem Commit wird gelesen…', + 'thread.originalFailed': 'Die Datei in diesem Commit konnte nicht gelesen werden: {message}', + 'thread.openOnHost': 'Auf GitHub ansehen', + 'thread.unresolvedCount': '{count} ungelöst', + 'thread.unresolvedAria': '{name} hat {count} ungelöste Threads; Threadliste öffnen', + 'thread.treeCount': '{count} ungelöste Threads', + 'thread.loadFailed': 'GitHub-Threads konnten nicht gelesen werden: {message}', + 'thread.inputFailed': 'Das Eingabefeld konnte diesen Thread nicht aufnehmen; später erneut versuchen', + 'thread.copy': 'Kopieren', + 'thread.copied': 'Kopiert', + 'thread.codeBlock': 'Codeblock', + 'thread.wrap': 'Zeilen umbrechen', + 'thread.unwrap': 'Zeilen nicht umbrechen', + 'thread.footnotes': 'Fußnoten', } /** Russian copy with the same keys. */ @@ -2160,6 +2412,48 @@ export const ru: Record = { 'toast.expandStale': 'Файл изменился после чтения изменений; обновите перед разворачиванием', 'toast.prompted': 'Отправлено: {text}', 'toast.promptFailed': 'Не удалось отправить сообщение', + 'thread.list': 'Обсуждения', + 'thread.listAria': 'Обсуждения ревью, всего {count}', + 'thread.close': 'Закрыть список обсуждений', + 'thread.filterAria': 'Фильтр обсуждений', + 'thread.filter.unresolved': 'Нерешённые', + 'thread.filter.outdated': 'Устаревшие', + 'thread.filter.resolved': 'Решённые', + 'thread.filter.mine': 'Ждут моего ответа', + 'thread.none': 'Нет обсуждений, подходящих под фильтр', + 'thread.empty': 'Обсуждений ревью пока нет', + 'thread.total': 'Обсуждений на {host}: {count}', + 'thread.truncated': 'Обсуждений слишком много; показаны первые {count}', + 'thread.openAll': 'Открыть на GitHub', + 'thread.replies': 'Ответов: {count}', + 'thread.fileLevel': 'Обсуждение файла', + 'thread.onCommit': 'в {commit}', + 'thread.originally': 'Изначально {range}', + 'thread.outdatedTag': 'Устарело', + 'thread.elsewhereTag': 'Нет в выбранных коммитах', + 'thread.author': 'Автор', + 'thread.bot': 'бот', + 'thread.deletedAuthor': 'Удалённая учётная запись', + 'thread.resolvedRow': 'Решённое обсуждение · {author}', + 'thread.expand': 'Развернуть', + 'thread.collapse': 'Свернуть', + 'thread.outdatedSection': 'Устаревших обсуждений: {count}. Их код изменили более поздние коммиты', + 'thread.viewOriginal': 'Открыть файл в том коммите', + 'thread.hideOriginal': 'Скрыть файл в том коммите', + 'thread.originalLoading': 'Чтение файла в том коммите…', + 'thread.originalFailed': 'Не удалось прочитать файл в том коммите: {message}', + 'thread.openOnHost': 'Открыть на GitHub', + 'thread.unresolvedCount': 'Нерешённых: {count}', + 'thread.unresolvedAria': 'В {name} нерешённых обсуждений: {count}. Открыть список обсуждений', + 'thread.treeCount': 'Нерешённых обсуждений: {count}', + 'thread.loadFailed': 'Не удалось прочитать обсуждения GitHub: {message}', + 'thread.inputFailed': 'Не удалось добавить обсуждение в поле ввода; повторите попытку позже', + 'thread.copy': 'Копировать', + 'thread.copied': 'Скопировано', + 'thread.codeBlock': 'Блок кода', + 'thread.wrap': 'Переносить строки', + 'thread.unwrap': 'Не переносить строки', + 'thread.footnotes': 'Сноски', } /** Portuguese copy with the same keys. */ @@ -2467,4 +2761,46 @@ export const pt: Record = { 'toast.expandStale': 'O arquivo mudou após a leitura das alterações; atualize antes de expandir', 'toast.prompted': 'Enviado: {text}', 'toast.promptFailed': 'Não foi possível enviar a mensagem', + 'thread.list': 'Discussões', + 'thread.listAria': 'Discussões de revisão, {count} no total', + 'thread.close': 'Fechar a lista de discussões', + 'thread.filterAria': 'Filtrar discussões', + 'thread.filter.unresolved': 'Não resolvidas', + 'thread.filter.outdated': 'Desatualizadas', + 'thread.filter.resolved': 'Resolvidas', + 'thread.filter.mine': 'Aguardando minha resposta', + 'thread.none': 'Nenhuma discussão corresponde a este filtro', + 'thread.empty': 'Ainda não há discussões de revisão', + 'thread.total': '{count} discussões no {host}', + 'thread.truncated': 'Há discussões demais; mostrando as primeiras {count}', + 'thread.openAll': 'Ver no GitHub', + 'thread.replies': '{count} respostas', + 'thread.fileLevel': 'Discussão do arquivo', + 'thread.onCommit': 'em {commit}', + 'thread.originally': 'Originalmente {range}', + 'thread.outdatedTag': 'Desatualizada', + 'thread.elsewhereTag': 'Fora dos commits escolhidos', + 'thread.author': 'Autor', + 'thread.bot': 'bot', + 'thread.deletedAuthor': 'Conta excluída', + 'thread.resolvedRow': 'Discussão resolvida · {author}', + 'thread.expand': 'Expandir', + 'thread.collapse': 'Recolher', + 'thread.outdatedSection': '{count} discussões desatualizadas: commits posteriores alteraram o código delas', + 'thread.viewOriginal': 'Ver o arquivo naquele commit', + 'thread.hideOriginal': 'Ocultar o arquivo daquele commit', + 'thread.originalLoading': 'Lendo o arquivo naquele commit…', + 'thread.originalFailed': 'Não foi possível ler o arquivo naquele commit: {message}', + 'thread.openOnHost': 'Ver no GitHub', + 'thread.unresolvedCount': '{count} não resolvidas', + 'thread.unresolvedAria': '{name} tem {count} discussões não resolvidas; abrir a lista de discussões', + 'thread.treeCount': '{count} discussões não resolvidas', + 'thread.loadFailed': 'Não foi possível ler as discussões do GitHub: {message}', + 'thread.inputFailed': 'O campo de entrada não pôde receber esta discussão; tente novamente mais tarde', + 'thread.copy': 'Copiar', + 'thread.copied': 'Copiado', + 'thread.codeBlock': 'Bloco de código', + 'thread.wrap': 'Quebrar linhas', + 'thread.unwrap': 'Não quebrar linhas', + 'thread.footnotes': 'Notas de rodapé', } diff --git a/packages/experimental/client-ui-forge/src/client/mount.ts b/packages/experimental/client-ui-forge/src/client/mount.ts index 34fee1a16b..84f58d4be7 100644 --- a/packages/experimental/client-ui-forge/src/client/mount.ts +++ b/packages/experimental/client-ui-forge/src/client/mount.ts @@ -7,19 +7,21 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' -import { IconBranchOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBranchOutlineRegular, type QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type {} from '@deepseek-ai/dsh-experimental-client-ui-forge/remote' +import type { ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame, ForgePanelChangeRequest } from '../types.ts' import { ReviewQueue } from './review-queue.ts' import { ViewedFiles } from './viewed-files.ts' import { formatReviewComment, reviewQuoteSummary, type ReviewSelection } from './review.ts' +import { formatThreadQuote, threadQuoteSummary } from './review-threads.ts' import { ActionToast } from './ActionToast.tsx' import { CHANGES_ID, changesDefinition } from './changes-address.ts' import { createChangesStore } from './changes-store.ts' @@ -147,16 +149,22 @@ function registerUi(ctx: Context): void { () => { notices.report('toast.promptFailed') }, ) } - const addReviewToInput = (sessionId: SessionId, selection: ReviewSelection): boolean => { + /** Insert one quote into the Session's main input, numbering it after the quotes of the same heading already there. */ + const quoteInInput = (sessionId: SessionId, heading: RegExp, quote: (index: number) => string, summary: QuoteSummary): boolean => { const scope = ctx.sessions.scope(sessionId) const conversation = scope?.get('conversation') if (scope === undefined || conversation === undefined) return false const input = conversation.input.for(scope) - const numbers = Array.from(input.state.getSnapshot().draft.matchAll(/(?:^|\n)(?:> )?## User Comment (\d+)/g), match => Number(match[1])) - if (!input.insertQuote(formatReviewComment(selection, Math.max(0, ...numbers) + 1), reviewQuoteSummary(selection.comment))) return false + const numbers = Array.from(input.state.getSnapshot().draft.matchAll(heading), match => Number(match[1])) + if (!input.insertQuote(quote(Math.max(0, ...numbers) + 1), summary)) return false input.focus() return true } + const addReviewToInput = (sessionId: SessionId, selection: ReviewSelection): boolean => quoteInInput(sessionId, + /(?:^|\n)(?:> )?## User Comment (\d+)/g, index => formatReviewComment(selection, index), reviewQuoteSummary(selection.comment)) + const addThreadToInput = (sessionId: SessionId, thread: ForgeReviewThread, url: string, range: string | null): boolean => + quoteInInput(sessionId, /(?:^|\n)(?:> )?## Review Thread (\d+)/g, index => formatThreadQuote(thread, url, index), + threadQuoteSummary(thread, range)) /** How toasts name a change request: its number, else its title, else its URL. */ const nameOf = (entry: ForgePanelChangeRequest): string => { const number = numberOf(entry) @@ -242,6 +250,8 @@ function registerUi(ctx: Context): void { viewedFiles: store.viewed, changeRequestText: (url, commit, path) => ctx.remote.forgePanel.changeRequestText(sessionId, url, commit, path), addReviewToInput: selection => addReviewToInput(sessionId, selection), + addThreadToInput: (thread, url, range) => addThreadToInput(sessionId, thread, url, range), + changeRequestReview: (url, headSha, signal) => ctx.remote.forgePanel.changeRequestReview(sessionId, url, headSha, signal), reviewQueue: ({ id, signal }) => { let queue = store.reviews.get(signal) if (queue === undefined) { diff --git a/packages/experimental/client-ui-forge/src/client/review-threads.ts b/packages/experimental/client-ui-forge/src/client/review-threads.ts new file mode 100644 index 0000000000..831b2acc0a --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/review-threads.ts @@ -0,0 +1,171 @@ +/** + * Where the changes view shows each review thread of a change request, which threads each filter of the thread list + * keeps, and the composer quote of a whole thread. + */ + +import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ForgeAnchor, ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ForgeCommitRange, ForgeDiffFile } from '../types.ts' + +/** The changes a view shows, which decides where a thread can sit. */ +export interface ThreadView { + /** Head commit the threads were read at. */ + readonly head: string + /** Commit the shown changes end at: the head, or the last chosen commit. */ + readonly end: string + /** Chosen consecutive commits, or null for the whole change request. */ + readonly commits: ForgeCommitRange | null +} + +/** + * Where a thread shows in the changes view: on lines of one side, at the top of its file, in its file's outdated + * section, or nowhere in the file because the chosen commits do not hold the lines it is on. + */ +export type ThreadPlace = + | { readonly kind: 'line'; readonly side: 'L' | 'R'; readonly startLine: number; readonly endLine: number } + | { readonly kind: 'file' } + | { readonly kind: 'outdated' } + | { readonly kind: 'elsewhere' } + +/** One filter of the thread list. */ +export type ThreadFilter = 'unresolved' | 'outdated' | 'resolved' | 'mine' + +/** Filters of the thread list in display order. */ +export const THREAD_FILTERS: readonly ThreadFilter[] = ['unresolved', 'outdated', 'resolved', 'mine'] + +/** + * Place a thread in a view. The whole change request shows a thread at its position on the head. Chosen commits show + * only a new-side thread whose lines number the file at the last chosen commit: its position on the head when the + * commits end at the head, else the lines it was written on at that commit. The old side of chosen commits starts + * at the parent of the first commit, which no thread position numbers. + * @param thread - the thread. + * @param view - the shown changes. + * @returns where the thread shows. + */ +export function placeThread(thread: ForgeReviewThread, view: ThreadView): ThreadPlace { + if (thread.subject === 'file') return { kind: 'file' } + if (view.commits === null) return thread.current === null ? { kind: 'outdated' } : onLines(thread.side, thread.current) + if (thread.side === 'R' && thread.current !== null && view.end === view.head) return onLines('R', thread.current) + if (thread.side === 'R' && thread.original !== null && thread.original.commit === view.end) return onLines('R', thread.original) + return thread.current === null ? { kind: 'outdated' } : { kind: 'elsewhere' } +} + +function onLines(side: 'L' | 'R', anchor: ForgeAnchor): ThreadPlace { + return { kind: 'line', side, startLine: anchor.startLine, endLine: anchor.endLine } +} + +/** + * Name a side and lines, such as `R12` or `L3–5`. + * @param side - old or new side. + * @param anchor - the lines. + * @returns the range label. + */ +export function anchorRange(side: 'L' | 'R', anchor: Pick): string { + return `${side}${anchor.startLine}${anchor.endLine === anchor.startLine ? '' : `–${anchor.endLine}`}` +} + +/** + * Find the collapsed unchanged lines that hold a line: the index of the hunk they precede, or the hunk count after the last hunk. + * @param file - the diff file. + * @param side - side the line numbers. + * @param line - the line. + * @returns the gap index, or undefined when a hunk shows the line. + */ +export function gapOf(file: ForgeDiffFile, side: 'L' | 'R', line: number): number | undefined { + for (const [index, hunk] of file.hunks.entries()) { + const start = side === 'L' ? hunk.oldStart : hunk.newStart + const count = side === 'L' ? hunk.oldLines : hunk.newLines + if (line < start) return index + if (line < start + count) return undefined + } + return file.hunks.length +} + +/** + * Whether the signed-in user should answer a thread: it is not resolved, the user wrote in it, and someone else wrote last. + * @param thread - the thread. + * @param viewer - login of the signed-in user, or null when the host names none. + * @returns whether the thread awaits the user's reply. + */ +export function awaitsViewer(thread: ForgeReviewThread, viewer: string | null): boolean { + if (viewer === null || thread.resolved) return false + const last = thread.comments.at(-1)?.author?.login + return last !== viewer && thread.comments.some(comment => comment.author?.login === viewer) +} + +/** + * Whether a thread passes a filter of the thread list; `outdated` keeps a line thread without a position on the head. + * @param thread - the thread. + * @param filter - the filter, or null for every thread. + * @param viewer - login of the signed-in user. + * @returns whether the list shows the thread. + */ +export function passesFilter(thread: ForgeReviewThread, filter: ThreadFilter | null, viewer: string | null): boolean { + switch (filter) { + case null: return true + case 'unresolved': return !thread.resolved + case 'outdated': return thread.subject === 'line' && thread.current === null + case 'resolved': return thread.resolved + case 'mine': return awaitsViewer(thread, viewer) + /* v8 ignore next -- closed ThreadFilter union exhaustiveness guard */ + default: return assertNever(filter, 'ThreadFilter') + } +} + +/** Lines of a recorded diff hunk shown before its last line, beside the lines the thread is on. */ +const EXCERPT_CONTEXT = 3 + +/** + * Cut a recorded diff hunk to its header and the lines that end it: the thread's lines on its side plus a few before. + * GitHub records the hunk up to the thread's last line. + * @param hunk - the recorded hunk, header first. + * @param side - side the thread's lines number. + * @param lines - how many lines the thread covers. + * @returns the header and the kept lines. + */ +export function hunkExcerpt(hunk: string, side: 'L' | 'R', lines: number): { readonly header: string | null; readonly lines: readonly string[] } { + const header = hunk.startsWith('@@') ? hunk.split('\n', 1).join('') : null + const body = (header === null ? hunk : hunk.slice(header.length + 1)).split('\n') + const other = side === 'L' ? '+' : '-' + const kept: string[] = [] + let wanted = lines + EXCERPT_CONTEXT + for (const line of body.toReversed()) { + if (wanted === 0) break + kept.push(line) + if (!line.startsWith(other)) wanted -= 1 + } + return { header, lines: kept.reverse() } +} + +/** + * Describe a composer quote of a thread by its file, range, and first comment. + * @param thread - the quoted thread. + * @param range - the thread's range label, or null for a file thread. + * @returns the composer's display of the quote. + */ +export function threadQuoteSummary(thread: ForgeReviewThread, range: string | null): QuoteSummary { + const name = thread.path.slice(thread.path.lastIndexOf('/') + 1) + return { label: range === null ? name : `${name} ${range}`, title: thread.path, detail: range ?? thread.path, body: thread.comments[0]?.body ?? '' } +} + +/** + * Format a whole thread for the ordinary, logged user message. Comment bodies are quoted line by line, so their + * Markdown cannot add headings to the message. + * @param thread - the thread. + * @param url - web page of the change request. + * @param index - one-based thread number in the composer. + * @returns Markdown content for an atomic composer quote. + */ +export function formatThreadQuote(thread: ForgeReviewThread, url: string, index: number): string { + const anchor = thread.current ?? thread.original + const where = thread.subject === 'file' || anchor === null ? 'Lines: whole file' + : `Side: ${thread.side}\nLines: ${anchor.startLine === anchor.endLine ? anchor.startLine : `${anchor.startLine}-${anchor.endLine}`}\nCommit: ${anchor.commit}` + const state = [thread.resolved ? 'resolved' : 'unresolved', ...thread.subject === 'line' && thread.current === null ? ['outdated'] : []].join(', ') + const fence = thread.diffHunk === null ? '' : '`'.repeat(Math.max(3, ...Array.from(thread.diffHunk.matchAll(/`+/g), match => match[0].length + 1))) + const hunk = thread.diffHunk === null ? '' : `Diff hunk:\n${fence}diff\n${thread.diffHunk}\n${fence}\n` + const comments = thread.comments.map(comment => `${comment.author === null ? 'Unknown author' : `@${comment.author.login}`} (${comment.createdAt}):\n` + + comment.body.split('\n').map(line => `> ${line}`).join('\n')).join('\n\n') + const link = thread.comments[0]?.url ?? url + return `## Review Thread ${index}\nFile: ${thread.path}\n${where}\nState: ${state}\nSource: ${link}\n${hunk}Comments:\n${comments}` +} diff --git a/packages/experimental/client-ui-forge/src/client/thread-state.ts b/packages/experimental/client-ui-forge/src/client/thread-state.ts new file mode 100644 index 0000000000..742cc8b1bd --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/thread-state.ts @@ -0,0 +1,124 @@ +/** Reads the review threads of the change request a changes view shows and places them in its files. */ + +import { useEffect, useMemo, useState } from 'react' +import type { ForgeReviewData, ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { LineThread } from './ReviewThreads.tsx' +import { placeThread, type ThreadPlace, type ThreadView } from './review-threads.ts' + +/** What the changes view knows about its change request when it reads threads. */ +export interface ThreadReadInput { + /** Whether the view is visible; a hidden view reads nothing. */ + readonly visible: boolean + /** Binding URL of the change request, or null for local changes. */ + readonly url: string | null + /** Head commit of the latest status, or null before one. */ + readonly head: string | null + /** Update time of the latest status, or null before one. */ + readonly updatedAt: string | null + /** Epoch milliseconds of the latest status, so every status asks the Host again; the Host answers from memory until the threads age. */ + readonly statusAt: number | null + /** Changes on each refresh. */ + readonly attempt: number + /** Read the threads at a head commit. */ + readonly read: (url: string, headSha: string, signal: AbortSignal) => Promise> +} + +/** The last threads read and why the latest read failed. */ +export interface ThreadReadState { + /** Threads of the last successful read for the URL, at any head, or null. */ + readonly review: ForgeReviewData | null + /** Message of the latest failed read, or null. */ + readonly failure: string | null + /** Whether the provider cannot read reviews, so the view shows no threads and no failure. */ + readonly unsupported: boolean +} + +/** + * Read the threads at the head of each status while the view is visible. A read refused because the head moved + * keeps the previous threads; the next status names the new head and reads again. + * @param input - the change request and the reader. + * @returns the last threads and the latest failure. + */ +export function useThreadRead(input: ThreadReadInput): ThreadReadState { + const { visible, url, head, updatedAt, statusAt, attempt, read } = input + const [state, setState] = useState( + { url: null, review: null, failure: null, unsupported: false }) + useEffect(() => { + if (!visible || url === null || head === null) return + const controller = new AbortController() + void read(url, head, controller.signal).then((result) => { + if (controller.signal.aborted) return + if (result.ok) { + setState({ url, review: result.value, failure: null, unsupported: false }) + return + } + const { code, message } = result.error + if (code === 'forgePanel/stale-head') return + setState(previous => ({ url, review: previous.url === url ? previous.review : null, + failure: code === 'forgePanel/review-unsupported' ? null : message, unsupported: code === 'forgePanel/review-unsupported' })) + }) + return () => { controller.abort() } + }, [visible, url, head, updatedAt, statusAt, attempt, read]) + return state.url === url ? state : { review: null, failure: null, unsupported: false } +} + +/** Threads of one file grouped by where they show, and how many of them are unresolved. */ +export interface PlacedFileThreads { + readonly lines: readonly LineThread[] + readonly file: readonly ForgeReviewThread[] + readonly outdated: readonly ForgeReviewThread[] + readonly unresolved: number +} + +/** One thread and where the shown changes place it. */ +export interface PlacedThread { + readonly thread: ForgeReviewThread + readonly place: ThreadPlace +} + +/** Every thread with its place and the threads of each file. */ +export interface PlacedThreads { + /** Every thread in host order with its place. */ + readonly entries: readonly PlacedThread[] + /** Threads of each path. */ + readonly files: ReadonlyMap + /** Unresolved threads by path, for the file tree. */ + readonly unresolved: ReadonlyMap +} + +const NONE: PlacedThreads = { entries: [], files: new Map(), unresolved: new Map() } + +/** + * Place the threads of a review in the shown changes. + * @param review - threads read at the head of the latest status, or null. + * @param view - the shown changes, or null before they load. + * @returns places, per-file groups, and unresolved counts. + */ +export function usePlacedThreads(review: ForgeReviewData | null, view: ThreadView | null): PlacedThreads { + const commits = view?.commits ?? null + const end = view?.end ?? null + return useMemo(() => { + if (review === null || end === null) return NONE + const entries: PlacedThread[] = [] + const files = new Map() + const unresolved = new Map() + for (const thread of review.threads) { + const place = placeThread(thread, { head: review.headSha, end, commits }) + entries.push({ thread, place }) + let group = files.get(thread.path) + if (group === undefined) { + group = { lines: [], file: [], outdated: [], unresolved: 0 } + files.set(thread.path, group) + } + if (place.kind === 'line') group.lines.push({ thread, place }) + else if (place.kind === 'file') group.file.push(thread) + else if (place.kind === 'outdated') group.outdated.push(thread) + if (!thread.resolved) { + group.unresolved += 1 + unresolved.set(thread.path, group.unresolved) + } + } + return { entries, files, unresolved } + }, [review, end, commits]) +} diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index a4b277e34c..9cbb1f9656 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -23,8 +23,11 @@ import baseCss from '../src/client/BaseMenu.module.css' import type { ForgeBaseBranch, ForgeBaseBranches, ForgeChangeRequestDiff, ForgeDiffFile, ForgeLocalDiff, ForgePanelChangeRequest, ForgePanelState, } from '../src/types.ts' -import type { ForgeCommitList } from '@deepseek-ai/dsh-experimental-forge/types' +import type { + ChangeRequestKey, ForgeCommitList, ForgeReviewData, ForgeReviewThread, ForgeThreadId, +} from '@deepseek-ai/dsh-experimental-forge/types' import { entry, ready, snapshot } from './client-fixtures.client.ts' +import { reviewComment, reviewThread } from './thread-fixtures.client.ts' const t = makeTranslate(en) const SESSION = SessionId('s1') @@ -138,6 +141,8 @@ const REFUSED = { staleHead: { ok: false, error: new RemoteError('forgePanel/stale-head', 'the head moved', {}) }, hostRefused: { ok: false, error: new RemoteError('forgePanel/diff-failed', 'diff too large', {}) }, failed: { ok: false, error: new RemoteError('gateway/bad-request', 'failed', {}) }, + reviewUnsupported: { ok: false, error: new RemoteError('forgePanel/review-unsupported', 'no review reads', {}) }, + reviewFailed: { ok: false, error: new RemoteError('forgePanel/review-failed', 'GitHub is down', {}) }, } satisfies Record> /** @@ -239,6 +244,8 @@ function mount(options: MountOptions = {}) { reviewQueue: vi.fn().mockReturnValue(new ReviewQueue('test-review')), submitReview: vi.fn().mockResolvedValue({ ok: true, value: { ok: true, url: URL7 } }), addReviewToInput: vi.fn().mockReturnValue(true), + addThreadToInput: vi.fn().mockReturnValue(true), + changeRequestReview: vi.fn(async () => REFUSED.reviewUnsupported), createChangeRequest: vi.fn(), report: vi.fn(), } @@ -1632,3 +1639,181 @@ describe('ChangesTab commits', () => { expect(m.view.queryByRole('button', { name: /^Commits shown/ })).toBeNull() }) }) + +describe('ChangesTab review threads', () => { + const source: ChangesSource = { kind: 'change', url: URL7 } + const OLD = 'b'.repeat(40) + const LARGE = file('logs/large.txt', { status: 'added', additions: 1001, + hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 1001, lines: Array.from({ length: 1001 }, (_, index) => `+${index}`) }] }) + const FILES = [APP, NOTES, GONE, LOGO, BIG, MOVED, LARGE] + const line = (id: string, path: string, side: 'L' | 'R', at: number, overrides: Partial = {}): ForgeReviewThread => reviewThread({ + id: id as ForgeThreadId, path, side, + current: { commit: HEAD, startLine: at, endLine: at }, original: { commit: HEAD, startLine: at, endLine: at }, + comments: [reviewComment({ body: `Comment ${id}` })], ...overrides, + }) + const THREADS: ForgeReviewThread[] = [ + line('on-row', 'src/app.ts', 'R', 4, { comments: [reviewComment({ body: 'Comment on-row', author: { login: 'octo', avatarUrl: null, bot: false } }), + reviewComment({ body: 'Reply', author: { login: 'lin', avatarUrl: null, bot: false } })] }), + line('old-side', 'src/app.ts', 'L', 11), + line('in-gap', 'src/app.ts', 'R', 8), + line('resolved', 'src/app.ts', 'R', 5, { resolved: true }), + line('outdated', 'src/app.ts', 'R', 0, { current: null, original: { commit: OLD, startLine: 9, endLine: 9 } }), + reviewThread({ id: 'file-thread' as ForgeThreadId, path: 'docs/notes.txt', subject: 'file', current: null, original: null, diffHunk: null, + comments: [reviewComment({ body: 'Comment file-thread' })] }), + line('beyond', 'docs/gone.txt', 'L', 3), + line('binary', 'assets/logo.png', 'R', 1), + line('oversized', 'logs/big.log', 'R', 1), + line('renamed', 'lib/b.txt', 'R', 1), + line('large', 'logs/large.txt', 'R', 1), + line('missing', 'src/missing.ts', 'R', 1), + line('missing-no-url', 'src/missing.ts', 'R', 2, { comments: [reviewComment({ url: null })] }), + ] + const REVIEW: ForgeReviewData = { headSha: HEAD, viewer: 'octo', threads: THREADS, truncated: false } + const reviewed = (review: ForgeReviewData = REVIEW) => (faces: Faces): void => { + faces.changeRequestDiff.mockImplementation(async () => ok({ ...CHANGE, files: FILES })) + faces.changeRequestReview.mockImplementation(async () => ok(review)) + } + /** Binding of #7 at the head whose latest status settled at `fetchedAt`. */ + const polledAt = (fetchedAt: number): ForgePanelChangeRequest => { + const bound = entry(7, snapshot(7)) + return { ...bound, update: { key: 'github:github.com/octo/demo#7' as ChangeRequestKey, fetchedAt, snapshot: snapshot(7) } } + } + const card = (m: ReturnType, id: string): HTMLElement | null => m.view.container.querySelector(`[data-forge-thread="${id}"]`) + /** Code of the row a thread follows, on the side its lines number. */ + const following = (m: ReturnType, id: string, side: 'L' | 'R' = 'R'): string | null => + card(m, id)?.previousElementSibling?.querySelector(side === 'L' ? '[data-old-line]' : '[data-new-line]')?.textContent ?? null + + it('reads threads at the head of the latest status and places them on rows, gaps, files, and outdated sections', async () => { + const m = mount({ source, setup: reviewed() }) + await settle() + expect(m.faces.changeRequestReview).toHaveBeenCalledWith(URL7, HEAD, expect.any(AbortSignal)) + const app = m.section('src/app.ts') + expect(following(m, 'on-row')).toBe('const y = 4') + expect(following(m, 'old-side', 'L')).toBe('const w = 12') + expect(card(m, 'in-gap')?.previousElementSibling?.getAttribute('data-forge-diff-gap')).toBe('1') + expect(card(m, 'resolved')?.textContent).toContain('Resolved thread · reviewer') + expect(within(app).getByRole('button', { name: 'app.ts has 4 unresolved threads; open the thread list' }).textContent).toBe('4 unresolved') + expect(app.querySelector('[data-forge-outdated-threads]')?.textContent).toContain('1 outdated threads') + fireEvent.click(within(card(m, 'outdated')!).getByRole('button', { name: en['thread.viewOriginal'] })) + expect(m.faces.changeRequestText).toHaveBeenLastCalledWith(URL7, OLD, 'src/app.ts') + expect(m.section('docs/notes.txt').firstElementChild?.nextElementSibling?.getAttribute('data-forge-thread')).toBe('file-thread') + expect(m.section('docs/gone.txt').querySelector(`.${css.code}`)?.lastElementChild?.getAttribute('data-forge-thread')).toBe('beyond') + for (const id of ['binary', 'oversized', 'renamed', 'large']) expect(card(m, id)).not.toBeNull() + expect(within(m.view.getByRole('navigation')).getByTitle('4 unresolved threads').textContent).toBe('4') + fireEvent.click(within(app).getByRole('button', { name: '4 unchanged lines' })) + await settle() + expect(following(m, 'in-gap')).toBe('const a7 = 8') + fireEvent.click(within(card(m, 'on-row')!).getByRole('button', { name: en['review.addToInput'] })) + expect(m.faces.addThreadToInput).toHaveBeenCalledWith(THREADS[0], URL7, 'R4') + m.faces.addThreadToInput.mockReturnValue(false) + fireEvent.click(within(card(m, 'on-row')!).getByRole('button', { name: en['review.addToInput'] })) + expect(m.faces.report).toHaveBeenLastCalledWith('thread.inputFailed') + }) + + it('opens the thread list, filters it, and opens each thread at its place', async () => { + const m = mount({ source, setup: reviewed({ ...REVIEW, truncated: true }) }) + await settle() + fireEvent.click(m.view.getByRole('button', { name: 'Review threads, 13 in total' })) + const list = m.view.getByRole('complementary', { name: 'Review threads, 13 in total' }) + expect(within(list).getAllByRole('button', { pressed: true }).map(button => button.textContent)).toEqual(['Unresolved 12']) + expect(within(list).getByText(/^Too many threads; showing the first 13/)).toBeDefined() + expect([...list.querySelectorAll('[data-forge-thread-group]')].map(group => group.getAttribute('data-forge-thread-group'))) + .toEqual(['assets/logo.png', 'docs/gone.txt', 'docs/notes.txt', 'lib/b.txt', 'logs/big.log', 'logs/large.txt', 'src/app.ts', 'src/missing.ts']) + scrolled.mockClear() + fireEvent.click(list.querySelector('[data-forge-thread-item="on-row"]')!) + expect(scrolled.mock.contexts).toContain(card(m, 'on-row')) + fireEvent.click(within(list).getByRole('button', { name: 'Resolved 1' })) + fireEvent.click(list.querySelector('[data-forge-thread-item="resolved"]')!) + expect(card(m, 'resolved')?.tagName).toBe('SECTION') + fireEvent.click(within(list).getByRole('button', { name: 'Resolved 1' })) + fireEvent.click(list.querySelector('[data-forge-thread-item="missing"]')!) + expect(m.faces.openLink).toHaveBeenLastCalledWith(THREADS[11]?.comments[0]?.url) + m.faces.openLink.mockClear() + fireEvent.click(list.querySelector('[data-forge-thread-item="missing-no-url"]')!) + expect(m.faces.openLink).not.toHaveBeenCalled() + fireEvent.click(within(list).getByRole('button', { name: en['thread.openAll'] })) + expect(m.faces.openLink).toHaveBeenLastCalledWith(URL7) + fireEvent.click(within(list).getByRole('button', { name: 'Awaiting my reply 1' })) + expect([...list.querySelectorAll('[data-forge-thread-item]')].map(item => item.getAttribute('data-forge-thread-item'))).toEqual(['on-row']) + fireEvent.click(within(list).getByRole('button', { name: en['thread.close'] })) + expect(m.view.queryByRole('complementary')).toBeNull() + scrolled.mockClear() + fireEvent.click(within(m.section('src/app.ts')).getByRole('button', { name: /^app\.ts has 4 unresolved threads/ })) + expect(scrolled.mock.contexts).toContain(m.view.container.querySelector('[data-forge-thread-group="src/app.ts"]')) + }) + + it('places only the threads whose lines number the last chosen commit, and shows the others after choosing all commits', async () => { + const two = '2'.repeat(40) + const threads = [line('at-head', 'src/app.ts', 'R', 4), line('written-on-two', 'src/app.ts', 'R', 3, { current: null, original: { commit: two, startLine: 3, endLine: 3 } })] + const m = mount({ source, setup: (faces) => { + faces.changeRequestDiff.mockImplementation(async (_url, commits) => ok({ ...CHANGE, headSha: commits?.to ?? HEAD })) + faces.changeRequestReview.mockImplementation(async () => ok({ ...REVIEW, threads })) + } }) + await settle() + m.store.actions.committed(TAB, { from: two, to: two }) + await settle() + expect(card(m, 'at-head')).toBeNull() + expect(following(m, 'written-on-two')).toBe('const a3 = 3') + fireEvent.click(m.view.getByRole('button', { name: 'Review threads, 2 in total' })) + const list = m.view.getByRole('complementary') + fireEvent.click(within(list).getByRole('button', { name: 'Unresolved 2' })) + expect(list.querySelector('[data-forge-thread-item="at-head"]')?.textContent).toContain(en['thread.elsewhereTag']) + fireEvent.click(list.querySelector('[data-forge-thread-item="at-head"]')!) + expect(m.store.getSnapshot().byTab[TAB]?.commits).toBeNull() + await settle() + expect(following(m, 'at-head')).toBe('const y = 4') + }) + + it('shows threads only at the head the diff and the latest status name, and reports a failed read with a retry', async () => { + const m = mount({ source, setup: (faces) => { faces.changeRequestReview.mockResolvedValue(REFUSED.reviewFailed) } }) + await settle() + expect(m.view.getByText('Could not read GitHub threads: GitHub is down')).toBeDefined() + m.faces.changeRequestReview.mockResolvedValue(ok({ ...REVIEW, threads: [line('on-row', 'src/app.ts', 'R', 4)] })) + fireEvent.click(within(m.view.getByText('Could not read GitHub threads: GitHub is down').closest('[role="status"]')!).getByRole('button', { name: en.retry })) + await settle() + expect(card(m, 'on-row')).not.toBeNull() + m.faces.changeRequestReview.mockResolvedValue(REFUSED.staleHead) + act(() => { m.panel.set({ state: ready({ changeRequests: [polledAt(2)] }), disconnected: false }) }) + await settle() + expect(card(m, 'on-row')).not.toBeNull() + m.faces.changeRequestReview.mockResolvedValue(REFUSED.reviewFailed) + act(() => { m.panel.set({ state: ready({ changeRequests: [polledAt(3)] }), disconnected: false }) }) + await settle() + expect(card(m, 'on-row')).not.toBeNull() + expect(m.view.getByText('Could not read GitHub threads: GitHub is down')).toBeDefined() + act(() => { m.panel.set({ state: ready({ changeRequests: [entry(7, snapshot(7, { headSha: OLD }))] }), disconnected: false }) }) + await settle() + expect(card(m, 'on-row')).toBeNull() + }) + + it('names no author when the host hides the change request\'s author', async () => { + const m = mount({ source, state: ready({ changeRequests: [entry(7, snapshot(7, { author: null }))] }), setup: reviewed({ ...REVIEW, + threads: [line('on-row', 'src/app.ts', 'R', 4, { comments: [reviewComment({ author: { login: 'octo', avatarUrl: null, bot: false } })] })] }) }) + await settle() + expect(within(card(m, 'on-row')!).queryByText(en['thread.author'])).toBeNull() + }) + + it('reads no threads for local changes or a hidden view, and shows none for a provider without review reads', async () => { + const local = mount() + await settle() + expect(local.faces.changeRequestReview).not.toHaveBeenCalled() + cleanup() + const m = mount({ source }) + await settle() + expect(m.faces.changeRequestReview).toHaveBeenCalledOnce() + expect(m.view.queryByRole('button', { name: /^Review threads/ })).toBeNull() + expect(m.view.queryByText(/^Could not read GitHub threads/)).toBeNull() + let answer: (value: RemoteResult) => void = () => {} + m.faces.changeRequestReview.mockImplementation(() => new Promise((resolve) => { answer = resolve })) + act(() => { m.panel.set({ state: ready({ changeRequests: [polledAt(2)] }), disconnected: false }) }) + expect(m.faces.changeRequestReview).toHaveBeenCalledTimes(2) + act(() => { m.visibility.set(false) }) + await act(async () => { answer(ok(REVIEW)) }) + act(() => { m.panel.set({ state: ready({ changeRequests: [polledAt(3)] }), disconnected: false }) }) + await settle() + expect(m.faces.changeRequestReview).toHaveBeenCalledTimes(2) + act(() => { m.visibility.set(true) }) + await settle() + expect(card(m, 'on-row')).toBeNull() + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts index 177a4784bd..46b484be84 100644 --- a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts @@ -12,7 +12,7 @@ import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SidebarRightTabDefinition, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type { - ChangeRequestSnapshot, ForgeCommitList, ForgeReviewRequest, ForgeReviewResult, + ChangeRequestSnapshot, ForgeCommitList, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, } from '@deepseek-ai/dsh-experimental-forge/types' import { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, type RemoteResult, type TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' @@ -31,6 +31,7 @@ import { SessionHoverForge } from '../src/client/SessionHoverForge.tsx' import { SessionRowForge } from '../src/client/SessionRowForge.tsx' import type { NoticeSource } from '../src/client/source.ts' import type { ReviewSelection } from '../src/client/review.ts' +import { reviewThread } from './thread-fixtures.client.ts' import { WorktreeProgress, type WorktreeProgressInjected } from '../src/client/WorktreeProgress.tsx' import { WorktreeSettings } from '../src/client/WorktreeSettings.tsx' import type { WorktreeSettingsInjected, WorktreeSettings as WorktreeSettingsFields } from '../src/client/worktree-settings-form.ts' @@ -183,6 +184,8 @@ async function base() { changeRequestCommits: vi.fn(async (_sessionId: SessionId, _url: string, _signal?: AbortSignal): Read => ({ ok: true, value: COMMITS })), changeRequestText: vi.fn(async (_sessionId: SessionId, _url: string, _commit: string, _path: string): Read => ({ ok: true, value: 'b\n' })), + changeRequestReview: vi.fn(async (_sessionId: SessionId, _url: string, headSha: string, _signal?: AbortSignal): Read => + ({ ok: true, value: { headSha, viewer: 'octo', threads: [], truncated: false } })), submitReview: vi.fn(async (_sessionId: SessionId, _url: string, _request: ForgeReviewRequest): Read => ({ ok: true, value: { ok: true, url: 'https://github.com/review' } })), } ctx.provide('remote.forgePanel', forgePanel) @@ -750,9 +753,16 @@ describe('mountForgePanel changes view', () => { expect(insertQuote.mock.calls[0]?.[1]).toEqual({ label: 'a.ts R1', title: 'a.ts', detail: 'R1', body: 'Check this' }) expect(resolve).toHaveBeenCalledWith(scope) expect(focus).toHaveBeenCalledTimes(2) + const thread = reviewThread({ path: 'src/a.ts' }) + expect(view.addThreadToInput(thread, 'https://github.com/octo/demo/pull/7', 'R3')).toBe(true) + expect(view.addThreadToInput(thread, 'https://github.com/octo/demo/pull/7', 'R3')).toBe(true) + expect(insertQuote.mock.calls.slice(2).map(([text, summary]) => [text.split('\n')[0], summary?.label])) + .toEqual([['## Review Thread 1', 'a.ts R3'], ['## Review Thread 2', 'a.ts R3']]) + insertQuote.mockClear() + focus.mockClear() insertQuote.mockReturnValueOnce(false) expect(view.addReviewToInput(selection)).toBe(false) - expect(focus).toHaveBeenCalledTimes(2) + expect(focus).not.toHaveBeenCalled() expect(send).not.toHaveBeenCalled() const request: ForgeReviewRequest = { kind: 'review', headSha: 'a', event: 'comment', body: 'Summary', comments: [] } expect(await view.submitReview('https://github.com/pr', request)).toMatchObject({ ok: true, value: { ok: true } }) @@ -823,6 +833,9 @@ describe('mountForgePanel changes view', () => { expect(f.forgePanel.localCommits).toHaveBeenCalledWith(S1, 'refs/heads/topic', signal) expect(await view.changeRequestCommits('https://github.com/octo/demo/pull/7', signal)).toEqual({ ok: true, value: COMMITS }) expect(f.forgePanel.changeRequestCommits).toHaveBeenCalledWith(S1, 'https://github.com/octo/demo/pull/7', signal) + expect(await view.changeRequestReview('https://github.com/octo/demo/pull/7', 'a'.repeat(40), signal)) + .toEqual({ ok: true, value: { headSha: 'a'.repeat(40), viewer: 'octo', threads: [], truncated: false } }) + expect(f.forgePanel.changeRequestReview).toHaveBeenCalledWith(S1, 'https://github.com/octo/demo/pull/7', 'a'.repeat(40), signal) expect(view.viewedFiles).toBe(f.changes(S1).viewedFiles) expect(view.viewedFiles).not.toBe(f.changes(S2).viewedFiles) view.reconnect() diff --git a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx new file mode 100644 index 0000000000..b6173e7853 --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx @@ -0,0 +1,214 @@ +// @vitest-environment jsdom +/** Host review thread cards, the outdated section, and the thread list rendered on their own. */ +import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { en } from '../src/client/locales.ts' +import { Avatar, OutdatedThreads, ThreadCard, type ThreadActions } from '../src/client/ReviewThreads.tsx' +import { ThreadList, type ThreadListProps } from '../src/client/ThreadList.tsx' +import { reviewComment, reviewThread } from './thread-fixtures.client.ts' + +const t = makeTranslate(en) +const OLD = 'b'.repeat(40) +const scrolled = vi.fn() + +beforeEach(() => { + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: scrolled }) +}) + +afterEach(() => { + cleanup() + scrolled.mockReset() + Reflect.deleteProperty(Element.prototype, 'scrollIntoView') +}) + +function actions(overrides: Partial = {}): ThreadActions { + return { + viewer: 'octo', author: 'octo', focused: null, + addToInput: vi.fn(() => true), openLink: vi.fn(), readText: vi.fn(async (): Promise> => ({ ok: true, value: 'a\nb\nc' })), + inputFailed: vi.fn(), + ...overrides, + } +} + +const outdated = (overrides: Partial = {}): ForgeReviewThread => reviewThread({ + id: 'old' as ForgeThreadId, current: null, original: { commit: OLD, startLine: 2, endLine: 3 }, diffHunk: '@@ -1 +1,3 @@\n a\n+b\n+c', ...overrides, +}) + +describe('Avatar', () => { + it('shows the image, or the login\'s first letter when there is none or it fails, or a mark for a deleted account', () => { + const view = render(<> + ) + const image = view.container.querySelector('img')! + expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') + fireEvent.error(image) + expect([...view.container.querySelectorAll('span')].map(span => span.textContent)).toEqual(['O', 'L', '?']) + }) +}) + +describe('ThreadCard', () => { + it('shows each comment with its author, time, role tags, and Markdown without raw HTML', () => { + const thread = reviewThread({ comments: [ + reviewComment({ author: { login: 'octo', avatarUrl: null, bot: false }, body: '**bold** raw', createdAt: 'not a time' }), + reviewComment({ author: { login: 'ci', avatarUrl: null, bot: true }, pending: true }), + reviewComment({ author: null }), + ] }) + const view = render() + const card = view.container.querySelector('[data-forge-thread]')! + expect(card.getAttribute('data-split')).toBe('') + expect(card.querySelector('strong')?.textContent).toBe('bold') + expect(card.textContent).not.toContain('') + expect(card.textContent).not.toContain('hidden') + expect(within(card as HTMLElement).getByText('not a time')).toBeDefined() + expect(card.querySelectorAll('[data-forge-thread-comment]')).toHaveLength(3) + expect(within(card as HTMLElement).getAllByText(en['thread.author'])).toHaveLength(1) + expect(within(card as HTMLElement).getByText(en['thread.bot'])).toBeDefined() + expect(within(card as HTMLElement).getByText(en['review.pending'])).toBeDefined() + expect(within(card as HTMLElement).getByText(en['thread.deletedAuthor'])).toBeDefined() + }) + + it('quotes the thread, reports a refused quote, and opens its page', () => { + const shared = actions({ addToInput: vi.fn(() => false) }) + const thread = reviewThread() + const view = render() + fireEvent.click(view.getByRole('button', { name: en['review.addToInput'] })) + expect(shared.addToInput).toHaveBeenCalledWith(thread, 'R3') + expect(shared.inputFailed).toHaveBeenCalledOnce() + fireEvent.click(view.getByRole('button', { name: en['thread.openOnHost'] })) + expect(shared.openLink).toHaveBeenCalledWith(thread.comments[0]?.url) + }) + + it('names a file thread and offers no page without one', () => { + const view = render() + expect(view.getByRole('region', { name: en['thread.fileLevel'] }).textContent).toContain(en['thread.fileLevel']) + expect(view.queryByRole('button', { name: en['thread.openOnHost'] })).toBeNull() + }) + + it('folds a resolved thread into one row until unfolded or jumped to, and folds it again after a jump', () => { + const thread = reviewThread({ resolved: true, comments: [] }) + const place = { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const + const view = render() + expect(view.container.textContent).toContain('Resolved thread · Deleted account') + fireEvent.click(view.getByRole('button', { name: en['thread.expand'] })) + expect(view.getByRole('region', { name: 'R3' })).toBeDefined() + fireEvent.click(view.getByRole('button', { name: en['thread.collapse'] })) + expect(view.queryByRole('region')).toBeNull() + view.rerender() + expect(view.getByRole('region', { name: 'R3' })).toBeDefined() + fireEvent.click(view.getByRole('button', { name: en['thread.collapse'] })) + expect(view.queryByRole('region')).toBeNull() + view.rerender() + expect(view.getByRole('region', { name: 'R3' })).toBeDefined() + }) + + it('shows an outdated thread at its original lines with the end of its hunk and the file at that commit', async () => { + let answer: (value: RemoteResult) => void = () => {} + const shared = actions({ readText: vi.fn(() => new Promise>((resolve) => { answer = resolve })) }) + const view = render() + const card = view.getByRole('region', { name: en['thread.fileLevel'] }) + expect(card.textContent).toContain(`Originally R2–3 · on ${OLD.slice(0, 7)}`) + expect([...card.querySelectorAll('[class*="excerptLine"]')].map(line => line.textContent)).toEqual(['@@ -1 +1,3 @@', ' a', '+b', '+c']) + fireEvent.click(view.getByRole('button', { name: en['thread.viewOriginal'] })) + expect(shared.readText).toHaveBeenCalledWith(OLD, 'src/a.ts') + expect(view.getByText(en['thread.originalLoading'])).toBeDefined() + await act(async () => { answer({ ok: true, value: 'one\ntwo\nthree\nfour' }) }) + const file = view.getByRole('region', { name: en['thread.viewOriginal'] }) + expect([...file.querySelectorAll('[class*="marked"]')].map(line => line.textContent)).toEqual(['2two', '3three']) + expect(scrolled.mock.contexts).toContain(file.querySelectorAll('[class*="marked"]')[0]) + fireEvent.click(view.getByRole('button', { name: en['thread.hideOriginal'] })) + expect(view.queryByRole('region', { name: en['thread.viewOriginal'] })).toBeNull() + const failing = { ...shared, readText: vi.fn(async (): Promise> => ({ ok: false, error: new RemoteError('forgePanel/diff-failed', 'gone', {}) })) } + view.rerender() + fireEvent.click(view.getByRole('button', { name: en['thread.viewOriginal'] })) + await act(async () => { await Promise.resolve() }) + expect(view.getByRole('alert').textContent).toBe('Could not read the file at that commit: gone') + }) + + it('offers no file at that commit for an old-side thread and no excerpt without a recorded hunk', () => { + const view = render() + expect(view.queryByRole('button', { name: en['thread.viewOriginal'] })).toBeNull() + expect(view.container.querySelector('[class*="excerpt"]')).toBeNull() + }) +}) + +describe('OutdatedThreads', () => { + it('renders nothing without threads, folds on request, and opens again for a later jump to one of its threads', () => { + const empty = render() + expect(empty.container.textContent).toBe('') + cleanup() + const view = render() + expect(view.container.textContent).toContain('1 outdated threads') + fireEvent.click(view.getByRole('button', { name: en['thread.collapse'] })) + expect(view.container.querySelector('[data-forge-thread]')).toBeNull() + view.rerender() + expect(view.container.querySelector('[data-forge-thread]')).toBeNull() + view.rerender() + expect(view.container.querySelector('[data-forge-thread]')).not.toBeNull() + fireEvent.click(view.getByRole('button', { name: en['thread.collapse'] })) + expect(view.container.querySelector('[data-forge-thread]')).toBeNull() + fireEvent.click(view.getByRole('button', { name: en['thread.expand'] })) + expect(view.container.querySelector('[data-forge-thread]')).not.toBeNull() + }) +}) + +describe('ThreadList', () => { + function list(overrides: Partial = {}) { + const props: ThreadListProps = { + entries: [], order: [], viewer: 'octo', truncated: false, host: 'github.com', url: 'https://github.com/octo/demo/pull/7', filter: null, + file: null, choose: vi.fn(), open: vi.fn(), openLink: vi.fn(), close: vi.fn(), t, ...overrides, + } + return { props, view: render() } + } + + it('says when there are no threads, when none pass the filter, and how many the host has', () => { + expect(list().view.getByText(en['thread.empty'])).toBeDefined() + cleanup() + const { view } = list({ entries: [{ thread: reviewThread(), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } }], filter: 'resolved' }) + expect(view.getByText(en['thread.none'])).toBeDefined() + expect(view.getByText(/^1 threads on github\.com/)).toBeDefined() + }) + + it('orders each file\'s threads from file threads to lines to outdated ones and describes each', () => { + const entries = [ + { thread: outdated({ id: 'gone' as ForgeThreadId, comments: [reviewComment(), reviewComment()] }), place: { kind: 'outdated' } as const }, + { thread: reviewThread({ id: 'late' as ForgeThreadId }), place: { kind: 'line', side: 'R', startLine: 9, endLine: 9 } as const }, + { thread: reviewThread({ id: 'away' as ForgeThreadId, current: null, original: null }), place: { kind: 'elsewhere' } as const }, + { thread: reviewThread({ id: 'whole' as ForgeThreadId, subject: 'file', current: null, original: null }), place: { kind: 'file' } as const }, + { thread: reviewThread({ id: 'early' as ForgeThreadId, comments: [reviewComment({ + body: '# Title\n> **Note** `code` [link](https://example.test) x', author: { login: 'ci', avatarUrl: null, bot: true } })] }), + place: { kind: 'line', side: 'L', startLine: 2, endLine: 4 } as const }, + ] + const { view, props } = list({ entries }) + const items = [...view.container.querySelectorAll('[data-forge-thread-item]')] + expect(items.map(item => item.getAttribute('data-forge-thread-item'))).toEqual(['whole', 'early', 'late', 'gone', 'away']) + expect(items.map(item => item.querySelector('[class*="meta"]')?.textContent)).toEqual([ + en['thread.fileLevel'], 'L2–4', 'R9', `Originally R2–3 · on ${OLD.slice(0, 7)} · 1 replies`, '', + ]) + expect(items[1]?.querySelector('[class*="excerpt"]')?.textContent).toBe('Title Note code link x') + expect(items[1]?.textContent).toContain(en['thread.bot']) + expect(items[3]?.textContent).toContain(en['thread.outdatedTag']) + expect(items[4]?.textContent).toContain(en['thread.elsewhereTag']) + fireEvent.click(items[3]!) + expect(props.open).toHaveBeenCalledWith(entries[0]) + fireEvent.click(view.getByRole('button', { name: 'Outdated 2' })) + expect(props.choose).toHaveBeenCalledWith('outdated') + fireEvent.click(view.getByRole('button', { name: en['thread.close'] })) + expect(props.close).toHaveBeenCalledOnce() + }) + + it('clears the active filter when it is chosen again and scrolls to a file\'s group', () => { + const entry = { thread: reviewThread({ path: 'b.ts' }), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const } + const other = { thread: reviewThread({ path: 'a.ts' }), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const } + const empty = { thread: reviewThread({ path: 'c.ts', comments: [] }), place: { kind: 'file' } as const } + const { view, props } = list({ entries: [empty, entry, other], order: ['b.ts'], filter: 'unresolved', file: 'a.ts' }) + expect([...view.container.querySelectorAll('[data-forge-thread-group]')].map(group => group.getAttribute('data-forge-thread-group'))) + .toEqual(['b.ts', 'a.ts', 'c.ts']) + expect(view.container.querySelector('[data-forge-thread-group="c.ts"] [class*="excerpt"]')?.textContent).toBe('') + expect(scrolled.mock.contexts).toEqual([view.container.querySelector('[data-forge-thread-group="a.ts"]')]) + fireEvent.click(view.getByRole('button', { name: 'Unresolved 3' })) + expect(props.choose).toHaveBeenCalledWith(null) + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts new file mode 100644 index 0000000000..2942e99f8a --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts @@ -0,0 +1,132 @@ +/** Placement, filters, excerpts, and composer quotes of host review threads. */ +import { describe, expect, it } from 'vitest' +import type { ForgeReviewComment, ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' +import { + anchorRange, awaitsViewer, formatThreadQuote, gapOf, hunkExcerpt, passesFilter, placeThread, threadQuoteSummary, THREAD_FILTERS, +} from '../src/client/review-threads.ts' +import type { ForgeDiffFile } from '../src/types.ts' +import { reviewComment, reviewThread } from './thread-fixtures.client.ts' + +const HEAD = 'a'.repeat(40) +const OLD = 'b'.repeat(40) +const MIDDLE = 'c'.repeat(40) + +describe('placeThread', () => { + const current = reviewThread({ current: { commit: HEAD, startLine: 3, endLine: 5 }, original: { commit: OLD, startLine: 2, endLine: 4 } }) + + it('places a thread on the whole change request at its position on the head, else in the outdated section', () => { + const whole = { head: HEAD, end: HEAD, commits: null } + expect(placeThread(current, whole)).toEqual({ kind: 'line', side: 'R', startLine: 3, endLine: 5 }) + expect(placeThread(reviewThread({ side: 'L', current: { commit: HEAD, startLine: 1, endLine: 1 } }), whole)) + .toEqual({ kind: 'line', side: 'L', startLine: 1, endLine: 1 }) + expect(placeThread(reviewThread({ current: null }), whole)).toEqual({ kind: 'outdated' }) + expect(placeThread(reviewThread({ subject: 'file', current: null, original: null }), whole)).toEqual({ kind: 'file' }) + }) + + it('places a new-side thread in chosen commits only where its lines number the last chosen commit', () => { + expect(placeThread(current, { head: HEAD, end: HEAD, commits: { from: MIDDLE, to: HEAD } })) + .toEqual({ kind: 'line', side: 'R', startLine: 3, endLine: 5 }) + expect(placeThread(current, { head: HEAD, end: OLD, commits: { from: OLD, to: OLD } })) + .toEqual({ kind: 'line', side: 'R', startLine: 2, endLine: 4 }) + expect(placeThread(current, { head: HEAD, end: MIDDLE, commits: { from: MIDDLE, to: MIDDLE } })).toEqual({ kind: 'elsewhere' }) + expect(placeThread(reviewThread({ side: 'L', current: { commit: HEAD, startLine: 1, endLine: 1 } }), + { head: HEAD, end: HEAD, commits: { from: HEAD, to: HEAD } })).toEqual({ kind: 'elsewhere' }) + expect(placeThread(reviewThread({ current: null, original: null }), { head: HEAD, end: OLD, commits: { from: OLD, to: OLD } })) + .toEqual({ kind: 'outdated' }) + expect(placeThread(reviewThread({ subject: 'file', current: null, original: null }), { head: HEAD, end: OLD, commits: { from: OLD, to: OLD } })) + .toEqual({ kind: 'file' }) + }) +}) + +it('names a side and its lines', () => { + expect(anchorRange('R', { startLine: 12, endLine: 12 })).toBe('R12') + expect(anchorRange('L', { startLine: 3, endLine: 5 })).toBe('L3–5') +}) + +it('finds the collapsed unchanged lines that hold a line on either side', () => { + const file: ForgeDiffFile = { + path: 'a.ts', previousPath: null, status: 'modified', binary: false, oversized: false, untracked: false, additions: 2, deletions: 2, + digest: 'digest', layer: 'implementation', + hunks: [ + { oldStart: 5, oldLines: 3, newStart: 5, newLines: 3, lines: [' a', '-b', '+b', ' c'] }, + { oldStart: 20, oldLines: 2, newStart: 22, newLines: 2, lines: ['-x', '+x', ' y'] }, + ], + } + expect(gapOf(file, 'R', 2)).toBe(0) + expect(gapOf(file, 'R', 6)).toBeUndefined() + expect(gapOf(file, 'R', 10)).toBe(1) + expect(gapOf(file, 'L', 21)).toBeUndefined() + expect(gapOf(file, 'R', 21)).toBe(1) + expect(gapOf(file, 'R', 40)).toBe(2) +}) + +describe('thread filters', () => { + const by = (login: string | null): ForgeReviewComment => + reviewComment({ author: login === null ? null : { login, avatarUrl: null, bot: false } }) + + it('awaits the viewer only on an unresolved thread the viewer wrote in and someone else wrote last', () => { + expect(awaitsViewer(reviewThread({ comments: [by('octo'), by('lin')] }), 'octo')).toBe(true) + expect(awaitsViewer(reviewThread({ comments: [by('lin'), by('octo')] }), 'octo')).toBe(false) + expect(awaitsViewer(reviewThread({ comments: [by('lin'), by(null)] }), 'octo')).toBe(false) + expect(awaitsViewer(reviewThread({ comments: [by('octo'), by(null)] }), 'octo')).toBe(true) + expect(awaitsViewer(reviewThread({ comments: [by('octo'), by('lin')], resolved: true }), 'octo')).toBe(false) + expect(awaitsViewer(reviewThread({ comments: [by('octo'), by('lin')] }), null)).toBe(false) + expect(awaitsViewer(reviewThread({ comments: [] }), 'octo')).toBe(false) + }) + + it('keeps the threads each filter names', () => { + const outdated = reviewThread({ current: null }) + const resolved = reviewThread({ resolved: true }) + const file = reviewThread({ subject: 'file', current: null, original: null }) + expect(THREAD_FILTERS).toEqual(['unresolved', 'outdated', 'resolved', 'mine']) + expect([outdated, resolved, file].map(thread => passesFilter(thread, null, null))).toEqual([true, true, true]) + expect([outdated, resolved, file].map(thread => passesFilter(thread, 'unresolved', null))).toEqual([true, false, true]) + expect([outdated, resolved, file].map(thread => passesFilter(thread, 'outdated', null))).toEqual([true, false, false]) + expect([outdated, resolved, file].map(thread => passesFilter(thread, 'resolved', null))).toEqual([false, true, false]) + expect(passesFilter(reviewThread({ comments: [by('octo'), by('lin')] }), 'mine', 'octo')).toBe(true) + }) +}) + +describe('hunkExcerpt', () => { + const hunk = '@@ -1,8 +1,9 @@\n a\n b\n-c\n+C\n+D\n d\n e' + + it('keeps the header and the thread\'s lines on its side with three before them', () => { + expect(hunkExcerpt(hunk, 'R', 1)).toEqual({ header: '@@ -1,8 +1,9 @@', lines: ['+C', '+D', ' d', ' e'] }) + expect(hunkExcerpt(hunk, 'L', 2)).toEqual({ header: '@@ -1,8 +1,9 @@', lines: [' a', ' b', '-c', '+C', '+D', ' d', ' e'] }) + }) + + it('keeps every line of a short hunk without a header', () => { + expect(hunkExcerpt(' a\n+b', 'R', 1)).toEqual({ header: null, lines: [' a', '+b'] }) + }) +}) + +describe('thread quotes', () => { + it('summarizes a quote by file name, range, and first comment', () => { + expect(threadQuoteSummary(reviewThread({ path: 'src/a.ts' }), 'R3')).toEqual({ label: 'a.ts R3', title: 'src/a.ts', detail: 'R3', body: 'First comment' }) + expect(threadQuoteSummary(reviewThread({ path: 'a.ts', comments: [] }), null)).toEqual({ label: 'a.ts', title: 'a.ts', detail: 'a.ts', body: '' }) + }) + + it('formats a line thread with its hunk, state, and every comment quoted line by line', () => { + const thread: ForgeReviewThread = reviewThread({ + current: { commit: HEAD, startLine: 3, endLine: 5 }, diffHunk: '@@ -1 +1 @@\n-```\n+````', + comments: [reviewComment({ body: '# Heading\nsecond line', url: 'https://github.com/octo/demo/pull/7#discussion_r1' }), + reviewComment({ author: null, body: 'reply' })], + }) + expect(formatThreadQuote(thread, 'https://github.com/octo/demo/pull/7', 2)).toBe([ + '## Review Thread 2', 'File: src/a.ts', 'Side: R', 'Lines: 3-5', `Commit: ${HEAD}`, 'State: unresolved', + 'Source: https://github.com/octo/demo/pull/7#discussion_r1', 'Diff hunk:', '`````diff', '@@ -1 +1 @@\n-```\n+````', '`````', + 'Comments:', '@reviewer (2026-11-05T08:00:00Z):', '> # Heading', '> second line', '', 'Unknown author (2026-11-05T08:00:00Z):', '> reply', + ].join('\n')) + }) + + it('formats an outdated single line at the lines it was written on, and a file thread without lines or hunk', () => { + const outdated = reviewThread({ current: null, resolved: true, original: { commit: OLD, startLine: 9, endLine: 9 }, diffHunk: null, + comments: [reviewComment({ url: null })] }) + expect(formatThreadQuote(outdated, 'https://github.com/octo/demo/pull/7', 1)).toBe([ + '## Review Thread 1', 'File: src/a.ts', 'Side: R', 'Lines: 9', `Commit: ${OLD}`, 'State: resolved, outdated', + 'Source: https://github.com/octo/demo/pull/7', 'Comments:', '@reviewer (2026-11-05T08:00:00Z):', '> First comment', + ].join('\n')) + const file = reviewThread({ subject: 'file', current: null, original: null, diffHunk: null }) + expect(formatThreadQuote(file, 'https://github.com/octo/demo/pull/7', 1)).toContain('File: src/a.ts\nLines: whole file\nState: unresolved\n') + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts b/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts new file mode 100644 index 0000000000..fe08cb6a60 --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts @@ -0,0 +1,43 @@ +/** Host review threads and comments shared by the browser-side thread tests. */ + +import type { ForgeCommentId, ForgeReviewComment, ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' + +let comments = 0 + +/** + * Build a comment by `reviewer` that has a web page. + * @param overrides - fields to replace. + * @returns the comment. + */ +export function reviewComment(overrides: Partial = {}): ForgeReviewComment { + comments += 1 + return { + id: `comment-${comments}` as ForgeCommentId, + author: { login: 'reviewer', avatarUrl: 'https://avatars.example/u/1', bot: false }, + body: 'First comment', + createdAt: '2026-11-05T08:00:00Z', + pending: false, + url: `https://github.com/octo/demo/pull/7#discussion_r${comments}`, + ...overrides, + } +} + +/** + * Build an unresolved new-side thread on line 3 of `src/a.ts`, written at the head `a…a` and placed there, with one comment. + * @param overrides - fields to replace. + * @returns the thread. + */ +export function reviewThread(overrides: Partial = {}): ForgeReviewThread { + return { + id: `thread-${comments + 1}` as ForgeThreadId, + path: 'src/a.ts', + side: 'R', + subject: 'line', + current: { commit: 'a'.repeat(40), startLine: 3, endLine: 3 }, + original: { commit: 'a'.repeat(40), startLine: 3, endLine: 3 }, + diffHunk: '@@ -1,3 +1,3 @@\n a\n-b\n+B', + resolved: false, + comments: [reviewComment()], + ...overrides, + } +} From fb1b204385873d0a93c259e415536e593ff45558 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 14:28:59 +0800 Subject: [PATCH 36/81] test(cua): record consent and pointer routing through a session --- .../experimental/cua/src/mouse-control.ts | 6 +- .../application-approval.client.spec.tsx | 7 +- snapshots/session/cua-native/session.v4.jsonl | 178 +++++++++--------- .../cua-native/system-prompt.expected.md | 3 +- .../cua-native/tool-schemas.expected.json | 19 ++ .../session/cua-ptc/system-prompt.expected.md | 3 +- .../system-prompt.expected.md | 3 +- 7 files changed, 129 insertions(+), 90 deletions(-) diff --git a/packages/experimental/cua/src/mouse-control.ts b/packages/experimental/cua/src/mouse-control.ts index 306cd43a2f..947482766f 100644 --- a/packages/experimental/cua/src/mouse-control.ts +++ b/packages/experimental/cua/src/mouse-control.ts @@ -11,7 +11,11 @@ export class MouseControl { constructor(private readonly ctx: Context) {} - /** @param agent Calling Agent. @returns Whether this Session explicitly approved moving the system pointer. */ + /** + * Select the native route without prompting the user. + * @param agent Calling Agent. + * @returns Whether this Session explicitly approved moving the system pointer. + */ allowed(agent: Agent): boolean { return this.decisions.get(agent.id) === true } diff --git a/packages/experimental/cua/tests/application-approval.client.spec.tsx b/packages/experimental/cua/tests/application-approval.client.spec.tsx index b6bc9d3d7c..13e79b8faa 100644 --- a/packages/experimental/cua/tests/application-approval.client.spec.tsx +++ b/packages/experimental/cua/tests/application-approval.client.spec.tsx @@ -6,7 +6,6 @@ import { afterEach, expect, it, vi } from 'vitest' import type { ApprovalCardInput } from '@deepseek-ai/dsh-client-ui-approval/client' import { PendingQuestion, createWaterfallRequest } from '@deepseek-ai/dsh-client-ui-user-questions/src/client/contract/slots.ts' import { ApplicationApproval, selectApplicationApproval, type ApplicationApprovalProps } from '../src/client/ApplicationApproval.tsx' -import { mouseControlDisplay } from '../src/locales.ts' import { en, zh } from '../src/client/locales.ts' type AskUserQuestionItem = PendingQuestion['questions'][number] @@ -87,7 +86,11 @@ it('uses the original question cancellation and refuses late approval', async () }) it('shows the mouse interruption and expiry with only session approval and refusal', () => { - const display = mouseControlDisplay + const display = { + question: { en: 'Move pointer?', zh: '允许移动鼠标指针吗?' }, + detail: { en: 'Until restart', zh: '重启后失效' }, + approve: { en: 'Allow for this session', zh: '本会话允许' }, + } const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-mouse-control', question: display.question.en, detail: display.detail.en, intent: { kind: 'approval', approve: display.approve.en, reject: 'Deny', display } }]) const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null) diff --git a/snapshots/session/cua-native/session.v4.jsonl b/snapshots/session/cua-native/session.v4.jsonl index 10a71dc794..7f49b59d88 100644 --- a/snapshots/session/cua-native/session.v4.jsonl +++ b/snapshots/session/cua-native/session.v4.jsonl @@ -12,286 +12,296 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"session/title","data":{"title":"Inspect CUA capabilities, select the","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949731596,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949731597,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1793949731597,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949731597,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889225,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889225,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"requestMouseControl\",\"screenshot\",\"scroll\",\"selectText\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732188,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732188,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949732188,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732188,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889794,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889794,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-unauthorized-getState"},"toolCallId":"cua-unauthorized-getState","content":[{"type":"text","text":"Error: APP_ACCESS_REQUIRED: Select the application with getApp and obtain user consent before reading or controlling it"}],"isError":true,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732206,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732206,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732206,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732206,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889811,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889811,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032889811,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889811,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"cua-unauthorized-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-unauthorized-screenshot"},"toolCallId":"cua-unauthorized-screenshot","content":[{"type":"text","text":"Error: APP_ACCESS_REQUIRED: Select the application with getApp and obtain user consent before reading or controlling it"}],"isError":true,"id":"{{message:9}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732224,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732224,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949732224,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732224,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889830,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889830,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032889830,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889830,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"cua-unauthorized-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-unauthorized-click"},"toolCallId":"cua-unauthorized-click","content":[{"type":"text","text":"Error: APP_ACCESS_REQUIRED: Select the application with getApp and obtain user consent before reading or controlling it"}],"isError":true,"id":"{{message:11}}"}},"sourceEventSeqs":[29],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732241,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732241,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}}},{"type":"chunk","time":1793949732241,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732241,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889846,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889846,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}}},{"type":"chunk","time":1794032889846,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889846,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"cua-native-2","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"test.fixture\"}}"}} {"type":"tool/result","data":{"turn":1,"step":5,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-2"},"toolCallId":"cua-native-2","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"name\":\"Fixture\",\"bundleId\":\"test.fixture\",\"running\":true}"}],"isError":false,"id":"{{message:13}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} -{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732276,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732276,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}}},{"type":"chunk","time":1793949732276,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732276,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889881,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889881,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}}},{"type":"chunk","time":1794032889881,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889881,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"cua-native-3","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"includeMenus\":true,\"details\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":6,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-3"},"toolCallId":"cua-native-3","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-1\",\"tree\":\"AX scope=window offsets=UTF-16 bounds=global-points\\n- [0] AXButton \\\"Increment\\\" id=\\\"increment\\\" actions=[\\\"AXPress\\\"] bounds=[60,80,100,20]\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 0\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\n - [3] AXMenuItem \\\"Fixture command\\\"\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:15}}"}},"sourceEventSeqs":[39],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:16}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732298,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732298,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}}},{"type":"chunk","time":1793949732298,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732298,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:16}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889899,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889899,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}}},{"type":"chunk","time":1794032889899,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889899,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":7,"callId":"cua-native-4","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementId\":\"0\",\"holdMs\":800}}"}} {"type":"tool/result","data":{"turn":1,"step":7,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-4"},"toolCallId":"cua-native-4","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:17}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"step/start","data":{"turn":1,"step":8}} -{"type":"assistant/message","data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:18}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732316,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732316,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732316,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732316,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:18}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889915,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889915,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032889915,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889915,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":8,"callId":"cua-native-5","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":8,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-5"},"toolCallId":"cua-native-5","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-2\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 1\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:19}}"}},"sourceEventSeqs":[49],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":8}} {"type":"step/start","data":{"turn":1,"step":9}} -{"type":"assistant/message","data":{"turn":1,"step":9,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:20}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732333,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732333,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732333,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732333,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":9,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:20}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889932,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889932,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032889932,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889932,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":9,"callId":"cua-native-6","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":9,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-6"},"toolCallId":"cua-native-6","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:21}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":9}} {"type":"step/start","data":{"turn":1,"step":10}} -{"type":"assistant/message","data":{"turn":1,"step":10,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:22}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732405,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732405,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949732405,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732405,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":10,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:22}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890010,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890010,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032890010,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890010,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":10,"callId":"cua-native-7","name":"cua","arguments":"{\"method\":\"listWindows\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} {"type":"tool/result","data":{"turn":1,"step":10,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-7"},"toolCallId":"cua-native-7","content":[{"type":"text","text":"[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true},{\"windowId\":8,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}]"}],"isError":false,"id":"{{message:23}}"}},"sourceEventSeqs":[59],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":10}} {"type":"step/start","data":{"turn":1,"step":11}} -{"type":"assistant/message","data":{"turn":1,"step":11,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:24}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732424,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732424,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1793949732424,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732424,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":11,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:24}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890027,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890027,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1794032890027,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890027,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":11,"callId":"cua-native-8","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} {"type":"tool/result","data":{"turn":1,"step":11,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-8"},"toolCallId":"cua-native-8","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"tree\":\"\",\"windows\":[{\"windowId\":8,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:25}}"}},"sourceEventSeqs":[64],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":11}} {"type":"step/start","data":{"turn":1,"step":12}} -{"type":"assistant/message","data":{"turn":1,"step":12,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:26}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732445,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732445,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1793949732445,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732445,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":12,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:26}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890044,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890044,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1794032890044,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890044,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":12,"callId":"cua-native-9","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} {"type":"tool/result","data":{"turn":1,"step":12,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-9"},"toolCallId":"cua-native-9","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":8,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:27}}"}},"sourceEventSeqs":[69],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":12}} {"type":"step/start","data":{"turn":1,"step":13}} -{"type":"assistant/message","data":{"turn":1,"step":13,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:28}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732481,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732481,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}}},{"type":"chunk","time":1793949732481,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732481,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":13,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:28}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890077,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890077,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}}},{"type":"chunk","time":1794032890077,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890077,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":13,"callId":"cua-native-10","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-3\",\"x\":0,\"y\":0}}"}} {"type":"tool/result","data":{"turn":1,"step":13,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-10"},"toolCallId":"cua-native-10","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:29}}"}},"sourceEventSeqs":[74],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":13}} {"type":"step/start","data":{"turn":1,"step":14}} -{"type":"assistant/message","data":{"turn":1,"step":14,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:30}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732502,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732502,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1793949732502,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732502,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":14,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:30}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890097,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890097,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}}},{"type":"chunk","time":1794032890097,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890097,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":14,"callId":"cua-native-11","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8}}"}} {"type":"tool/result","data":{"turn":1,"step":14,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-11"},"toolCallId":"cua-native-11","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"tree\":\"\",\"windows\":[{\"windowId\":8,\"appName\":\"Fixture\",\"title\":\"AXless canvas\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:31}}"}},"sourceEventSeqs":[79],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":14}} {"type":"step/start","data":{"turn":1,"step":15}} -{"type":"assistant/message","data":{"turn":1,"step":15,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:32}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732523,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732523,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732523,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732523,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":15,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:32}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890116,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890116,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890116,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890116,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":15,"callId":"cua-native-show","name":"cua","arguments":"{\"method\":\"show\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":15,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-show"},"toolCallId":"cua-native-show","content":[{"type":"text","text":"{\"dispatched\":true,\"windowId\":7,\"foregroundChanged\":false}"}],"isError":false,"id":"{{message:33}}"}},"sourceEventSeqs":[84],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":15}} {"type":"step/start","data":{"turn":1,"step":16}} -{"type":"assistant/message","data":{"turn":1,"step":16,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:34}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732546,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732546,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949732546,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732546,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":16,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:34}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890134,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890134,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032890134,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890134,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":16,"callId":"cua-native-activate-first","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} {"type":"tool/result","data":{"turn":1,"step":16,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-activate-first"},"toolCallId":"cua-native-activate-first","content":[{"type":"text","text":"{\"dispatched\":true,\"foregroundChanged\":true,\"foreground\":{\"requestId\":\"cua-native-activate-first\",\"targetPid\":9,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true}}"}],"isError":false,"id":"{{message:35}}"}},"sourceEventSeqs":[89],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":16}} {"type":"step/start","data":{"turn":1,"step":17}} -{"type":"assistant/message","data":{"turn":1,"step":17,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:36}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732573,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732573,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949732573,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732573,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":17,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:36}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890151,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890151,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032890151,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890151,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":17,"callId":"cua-native-activate-repeat","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} {"type":"tool/result","data":{"turn":1,"step":17,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-activate-repeat"},"toolCallId":"cua-native-activate-repeat","content":[{"type":"text","text":"{\"dispatched\":true,\"foregroundChanged\":false,\"foreground\":{\"requestId\":\"cua-native-activate-repeat\",\"targetPid\":9,\"before\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":false}}"}],"isError":false,"id":"{{message:37}}"}},"sourceEventSeqs":[94],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":17}} {"type":"step/start","data":{"turn":1,"step":18}} -{"type":"assistant/message","data":{"turn":1,"step":18,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:38}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732598,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732598,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}}},{"type":"chunk","time":1793949732598,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732598,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":18,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:38}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890168,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890168,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}}},{"type":"chunk","time":1794032890168,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890168,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":18,"callId":"cua-native-transition","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"Return\"}}"}} {"type":"tool/result","data":{"turn":1,"step":18,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-transition"},"toolCallId":"cua-native-transition","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:39}}"}},"sourceEventSeqs":[99],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":18}} {"type":"step/start","data":{"turn":1,"step":19}} -{"type":"assistant/message","data":{"turn":1,"step":19,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:40}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732622,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732622,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732622,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732622,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":19,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:40}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890186,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890186,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890186,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890186,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":19,"callId":"cua-native-stale-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":19,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-stale-read"},"toolCallId":"cua-native-stale-read","content":[{"type":"text","text":"Error: STALE_SNAPSHOT: Accessibility tree changed during observation; read fresh getAccessibilityTree"}],"isError":true,"id":"{{message:41}}"}},"sourceEventSeqs":[104],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":19}} {"type":"step/start","data":{"turn":1,"step":20}} -{"type":"assistant/message","data":{"turn":1,"step":20,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:42}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732643,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732643,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949732643,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732643,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":20,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:42}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890203,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890203,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032890203,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890203,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":20,"callId":"cua-native-stale-index","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-2\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":20,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-stale-index"},"toolCallId":"cua-native-stale-index","content":[{"type":"text","text":"Error: STALE_SNAPSHOT: Read fresh state before acting"}],"isError":true,"id":"{{message:43}}"}},"sourceEventSeqs":[109],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":20}} {"type":"step/start","data":{"turn":1,"step":21}} -{"type":"assistant/message","data":{"turn":1,"step":21,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:44}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732663,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732663,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}}},{"type":"chunk","time":1793949732663,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732663,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":21,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:44}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890219,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890219,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}}},{"type":"chunk","time":1794032890219,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890219,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":21,"callId":"cua-native-other-window","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}"}} {"type":"tool/result","data":{"turn":1,"step":21,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-other-window"},"toolCallId":"cua-native-other-window","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:45}}"}},"sourceEventSeqs":[114],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":21}} {"type":"step/start","data":{"turn":1,"step":22}} -{"type":"assistant/message","data":{"turn":1,"step":22,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:46}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732687,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732687,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732687,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732687,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":22,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:46}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890236,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890236,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890236,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890236,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":22,"callId":"cua-native-fresh-read","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":22,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-fresh-read"},"toolCallId":"cua-native-fresh-read","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-5\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 1\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:47}}"}},"sourceEventSeqs":[119],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":22}} {"type":"step/start","data":{"turn":1,"step":23}} -{"type":"assistant/message","data":{"turn":1,"step":23,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:48}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732710,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732710,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949732710,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732710,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":23,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:48}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890253,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890253,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032890253,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890253,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":23,"callId":"cua-native-fresh-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-5\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":23,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-fresh-click"},"toolCallId":"cua-native-fresh-click","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:49}}"}},"sourceEventSeqs":[124],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":23}} {"type":"step/start","data":{"turn":1,"step":24}} -{"type":"assistant/message","data":{"turn":1,"step":24,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:50}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732733,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732733,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732733,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732733,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":24,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:50}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890269,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890269,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890269,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890269,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":24,"callId":"cua-native-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":24,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-verify"},"toolCallId":"cua-native-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-6\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 2\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:51}}"}},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":24}} {"type":"step/start","data":{"turn":1,"step":25}} -{"type":"assistant/message","data":{"turn":1,"step":25,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:52}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732756,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732756,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1793949732756,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732756,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":25,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:52}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890285,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890285,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1794032890285,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890285,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":25,"callId":"cua-native-geometry-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} {"type":"tool/result","data":{"turn":1,"step":25,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-observe"},"toolCallId":"cua-native-geometry-observe","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 2\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":9,\"appName\":\"Fixture\",\"title\":\"Transformed window\",\"bounds\":{\"x\":16,\"y\":512,\"width\":131,\"height\":145},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:53}}"}},"sourceEventSeqs":[134],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":25}} {"type":"step/start","data":{"turn":1,"step":26}} -{"type":"assistant/message","data":{"turn":1,"step":26,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:54}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732777,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732777,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1793949732777,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732777,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":26,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:54}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890301,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890301,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1794032890301,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890301,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":26,"callId":"cua-native-geometry-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} {"type":"tool/result","data":{"turn":1,"step":26,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-capture"},"toolCallId":"cua-native-geometry-capture","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:55}}"}},"sourceEventSeqs":[139],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":26}} {"type":"step/start","data":{"turn":1,"step":27}} -{"type":"assistant/message","data":{"turn":1,"step":27,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:56}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732798,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732798,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}}},{"type":"chunk","time":1793949732798,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732798,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":27,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:56}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890319,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890319,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}}},{"type":"chunk","time":1794032890319,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890319,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":27,"callId":"cua-native-geometry-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-7\",\"x\":20,\"y\":20,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":27,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-click"},"toolCallId":"cua-native-geometry-click","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:57}}"}},"sourceEventSeqs":[144],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":27}} {"type":"step/start","data":{"turn":1,"step":28}} -{"type":"assistant/message","data":{"turn":1,"step":28,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:58}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732822,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732822,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}}},{"type":"chunk","time":1793949732822,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732822,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":28,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:58}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890336,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890336,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}}},{"type":"chunk","time":1794032890336,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890336,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":28,"callId":"cua-native-geometry-scroll","name":"cua","arguments":"{\"method\":\"scroll\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"x\":20,\"y\":20,\"deltaLinesX\":0,\"deltaLinesY\":-1,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":28,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-scroll"},"toolCallId":"cua-native-geometry-scroll","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:59}}"}},"sourceEventSeqs":[149],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":28}} {"type":"step/start","data":{"turn":1,"step":29}} -{"type":"assistant/message","data":{"turn":1,"step":29,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:60}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732845,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732845,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}}},{"type":"chunk","time":1793949732845,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732845,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":29,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:60}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890353,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890353,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}}},{"type":"chunk","time":1794032890353,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890353,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":29,"callId":"cua-native-geometry-drag","name":"cua","arguments":"{\"method\":\"drag\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9,\"fromX\":20,\"fromY\":20,\"toX\":30,\"toY\":30,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":29,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-drag"},"toolCallId":"cua-native-geometry-drag","content":[{"type":"text","text":"Error: WINDOW_CHANGED: Window display and accessibility bounds disagree. Read fresh state; if unchanged, use AX actions or explicitly restore the window before capture or coordinate input"}],"isError":true,"id":"{{message:61}}"}},"sourceEventSeqs":[154],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":29}} {"type":"step/start","data":{"turn":1,"step":30}} -{"type":"assistant/message","data":{"turn":1,"step":30,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:62}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732870,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732870,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949732870,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732870,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":30,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:62}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890372,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890372,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032890372,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890372,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":30,"callId":"cua-native-geometry-ax","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-7\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":30,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-ax"},"toolCallId":"cua-native-geometry-ax","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:63}}"}},"sourceEventSeqs":[159],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":30}} {"type":"step/start","data":{"turn":1,"step":31}} -{"type":"assistant/message","data":{"turn":1,"step":31,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:64}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732900,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732900,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1793949732900,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732900,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":31,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:64}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890389,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890389,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}}},{"type":"chunk","time":1794032890389,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890389,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":31,"callId":"cua-native-geometry-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":9}}"}} {"type":"tool/result","data":{"turn":1,"step":31,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-geometry-verify"},"toolCallId":"cua-native-geometry-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":9,\"snapshotId\":\"fixture-snapshot-8\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":9,\"appName\":\"Fixture\",\"title\":\"Transformed window\",\"bounds\":{\"x\":16,\"y\":512,\"width\":131,\"height\":145},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:65}}"}},"sourceEventSeqs":[164],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":31}} {"type":"step/start","data":{"turn":1,"step":32}} -{"type":"assistant/message","data":{"turn":1,"step":32,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:66}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732924,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732924,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}}},{"type":"chunk","time":1793949732924,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732924,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":32,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:66}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890407,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890407,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}}},{"type":"chunk","time":1794032890407,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890407,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":32,"callId":"cua-capture-busy-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F11\"}}"}} {"type":"tool/result","data":{"turn":1,"step":32,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy-arm"},"toolCallId":"cua-capture-busy-arm","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:67}}"}},"sourceEventSeqs":[169],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":32}} {"type":"step/start","data":{"turn":1,"step":33}} -{"type":"assistant/message","data":{"turn":1,"step":33,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:68}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732947,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732947,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732947,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732947,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":33,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:68}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890424,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890424,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890424,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890424,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":33,"callId":"cua-capture-busy","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":33,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy"},"toolCallId":"cua-capture-busy","content":[{"type":"text","text":"Error: CAPTURE_BUSY: A previous system capture is still pending; other native operations remain available\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-capture-busy\",\"stage\":\"helper.failed\",\"elapsedMs\":1,\"errorCode\":\"CAPTURE_BUSY\"}"}],"isError":true,"id":"{{message:69}}"}},"sourceEventSeqs":[174],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":33}} {"type":"step/start","data":{"turn":1,"step":34}} -{"type":"assistant/message","data":{"turn":1,"step":34,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:70}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732969,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732969,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949732969,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732969,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":34,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:70}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890441,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890441,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890441,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890441,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":34,"callId":"cua-capture-busy-ax","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":34,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-busy-ax"},"toolCallId":"cua-capture-busy-ax","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-9\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:71}}"}},"sourceEventSeqs":[179],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":34}} {"type":"step/start","data":{"turn":1,"step":35}} -{"type":"assistant/message","data":{"turn":1,"step":35,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:72}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949732988,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949732988,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}}},{"type":"chunk","time":1793949732988,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949732988,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":35,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:72}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890457,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890457,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}}},{"type":"chunk","time":1794032890457,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890457,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":35,"callId":"cua-capture-callback","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F10\"}}"}} {"type":"tool/result","data":{"turn":1,"step":35,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-callback"},"toolCallId":"cua-capture-callback","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:73}}"}},"sourceEventSeqs":[184],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":35}} {"type":"step/start","data":{"turn":1,"step":36}} -{"type":"assistant/message","data":{"turn":1,"step":36,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:74}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733007,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733007,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733007,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733007,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":36,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:74}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890475,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890475,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890475,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890475,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":36,"callId":"cua-capture-recovered","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":36,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-capture-recovered"},"toolCallId":"cua-capture-recovered","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:75}}"}},"sourceEventSeqs":[189],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":36}} {"type":"step/start","data":{"turn":1,"step":37}} -{"type":"assistant/message","data":{"turn":1,"step":37,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:76}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733041,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733041,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1793949733041,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733041,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":37,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:76}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890537,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890537,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}}},{"type":"chunk","time":1794032890537,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890537,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":37,"callId":"cua-hover-enter","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-9\",\"elementId\":\"0\"}}"}} {"type":"tool/result","data":{"turn":1,"step":37,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-enter"},"toolCallId":"cua-hover-enter","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:77}}"}},"sourceEventSeqs":[194],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":37}} {"type":"step/start","data":{"turn":1,"step":38}} -{"type":"assistant/message","data":{"turn":1,"step":38,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:78}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733060,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733060,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733060,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733060,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":38,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:78}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890554,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890554,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890554,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890554,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":38,"callId":"cua-hover-observe","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":38,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-observe"},"toolCallId":"cua-hover-observe","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 3\\\"\\n- AXStaticText \\\"Hover information\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:79}}"}},"sourceEventSeqs":[199],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":38}} {"type":"step/start","data":{"turn":1,"step":39}} -{"type":"assistant/message","data":{"turn":1,"step":39,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:80}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733087,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733087,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733087,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733087,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":39,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:80}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890576,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890576,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890576,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890576,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":39,"callId":"cua-hover-capture","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} {"type":"tool/result","data":{"turn":1,"step":39,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-capture"},"toolCallId":"cua-hover-capture","content":[{"type":"text","text":"{\"pixelWidth\":1,\"pixelHeight\":1,\"windowId\":7,\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"coordinateSpace\":\"screenshot-pixels\"}"},{"type":"image","attachment":{"attachmentId":"sha256:999f1d1527ee7e79266f16add5430fff76b1225d742464a5b1ff1f02971bb8ee","mediaType":"image/png","width":1,"height":1,"bytes":90}}],"isError":false,"id":"{{message:81}}"}},"sourceEventSeqs":[204],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":39}} {"type":"step/start","data":{"turn":1,"step":40}} -{"type":"assistant/message","data":{"turn":1,"step":40,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:82}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733130,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733130,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1793949733130,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733130,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":40,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:82}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890612,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890612,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890612,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890612,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":40,"callId":"cua-hover-popup","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":40,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-popup"},"toolCallId":"cua-hover-popup","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:83}}"}},"sourceEventSeqs":[209],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":40}} {"type":"step/start","data":{"turn":1,"step":41}} -{"type":"assistant/message","data":{"turn":1,"step":41,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:84}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733147,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733147,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1793949733147,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733147,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":41,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:84}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890629,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890629,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890629,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890629,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":41,"callId":"cua-hover-click","name":"cua","arguments":"{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-10\",\"x\":100,\"y\":200,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":41,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-click"},"toolCallId":"cua-hover-click","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:85}}"}},"sourceEventSeqs":[214],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":41}} {"type":"step/start","data":{"turn":1,"step":42}} -{"type":"assistant/message","data":{"turn":1,"step":42,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:86}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733164,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733164,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1793949733164,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733164,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":42,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:86}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890645,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890645,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890645,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890646,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":42,"callId":"cua-hover-exit","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":42,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-exit"},"toolCallId":"cua-hover-exit","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"accessibility\",\"systemPointerMoved\":false}"}],"isError":false,"id":"{{message:87}}"}},"sourceEventSeqs":[219],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":42}} {"type":"step/start","data":{"turn":1,"step":43}} -{"type":"assistant/message","data":{"turn":1,"step":43,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:88}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733183,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733183,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1793949733183,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733183,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":43,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:88}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890662,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890662,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890662,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890662,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":43,"callId":"cua-hover-unavailable","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":0,\"y\":200,\"relative\":true}}"}} {"type":"tool/result","data":{"turn":1,"step":43,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-unavailable"},"toolCallId":"cua-hover-unavailable","content":[{"type":"text","text":"Error: HOVER_UNAVAILABLE: No AX alternate UI at this target. No input was sent and the pointer did not move."}],"isError":true,"id":"{{message:89}}"}},"sourceEventSeqs":[224],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":43}} {"type":"step/start","data":{"turn":1,"step":44}} -{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733201,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733201,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733201,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733201,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-verify"},"toolCallId":"cua-hover-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-11\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890679,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890679,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}} +{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-pointer-request"},"toolCallId":"cua-pointer-request","content":[{"type":"text","text":"true"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":44}} {"type":"step/start","data":{"turn":1,"step":45}} -{"type":"assistant/message","data":{"turn":1,"step":45,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:92}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733220,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733220,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}}},{"type":"chunk","time":1793949733220,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733220,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":45,"callId":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":45,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-background"},"toolCallId":"cua-paste-background","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:93}}"}},"sourceEventSeqs":[234],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":45,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:92}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890696,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890696,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890696,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890696,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":45,"callId":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":45,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-system-hover"},"toolCallId":"cua-system-hover","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"system-pointer\",\"systemPointerMoved\":true}"}],"isError":false,"id":"{{message:93}}"}},"sourceEventSeqs":[234],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":45}} {"type":"step/start","data":{"turn":1,"step":46}} -{"type":"assistant/message","data":{"turn":1,"step":46,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:94}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733238,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733238,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1793949733238,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733238,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":46,"callId":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":46,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-unread"},"toolCallId":"cua-paste-unread","content":[{"type":"text","text":"Error: PASTE_NOT_CONSUMED: Paste command was dispatched, but clipboard data was not requested within the read deadline. Bring the target app to the foreground and focus its text input. Inspect the contents before deciding whether to retry; a delayed paste may still run."}],"isError":true,"id":"{{message:95}}"}},"sourceEventSeqs":[239],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":46,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:94}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890712,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890712,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890712,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890712,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":46,"callId":"cua-hover-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":46,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-verify"},"toolCallId":"cua-hover-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-11\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:95}}"}},"sourceEventSeqs":[239],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":46}} {"type":"step/start","data":{"turn":1,"step":47}} -{"type":"assistant/message","data":{"turn":1,"step":47,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:96}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733260,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733260,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733260,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733260,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":47,"callId":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":47,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-inspect"},"toolCallId":"cua-paste-inspect","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-12\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:97}}"}},"sourceEventSeqs":[244],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":47,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:96}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890729,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890729,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}}},{"type":"chunk","time":1794032890729,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890730,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":47,"callId":"cua-paste-background","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F9\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":47,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-background"},"toolCallId":"cua-paste-background","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:97}}"}},"sourceEventSeqs":[244],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":47}} {"type":"step/start","data":{"turn":1,"step":48}} -{"type":"assistant/message","data":{"turn":1,"step":48,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:98}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733280,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733280,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1793949733280,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733280,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":48,"callId":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":48,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-activate"},"toolCallId":"cua-paste-activate","content":[{"type":"text","text":"{\"dispatched\":true,\"foregroundChanged\":true,\"foreground\":{\"requestId\":\"cua-paste-activate\",\"targetPid\":9,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true}}"}],"isError":false,"id":"{{message:99}}"}},"sourceEventSeqs":[249],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":48,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:98}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890746,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890746,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1794032890746,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890747,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":48,"callId":"cua-paste-unread","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":48,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-unread"},"toolCallId":"cua-paste-unread","content":[{"type":"text","text":"Error: PASTE_NOT_CONSUMED: Paste command was dispatched, but clipboard data was not requested within the read deadline. Bring the target app to the foreground and focus its text input. Inspect the contents before deciding whether to retry; a delayed paste may still run."}],"isError":true,"id":"{{message:99}}"}},"sourceEventSeqs":[249],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":48}} {"type":"step/start","data":{"turn":1,"step":49}} -{"type":"assistant/message","data":{"turn":1,"step":49,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:100}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733300,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733300,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733300,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733300,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":49,"callId":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":49,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-focus"},"toolCallId":"cua-paste-focus","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-13\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:101}}"}},"sourceEventSeqs":[254],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":49,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:100}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890765,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890765,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890765,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890765,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":49,"callId":"cua-paste-inspect","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":49,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-inspect"},"toolCallId":"cua-paste-inspect","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-12\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:101}}"}},"sourceEventSeqs":[254],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":49}} {"type":"step/start","data":{"turn":1,"step":50}} -{"type":"assistant/message","data":{"turn":1,"step":50,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:102}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733320,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733320,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1793949733320,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733320,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":50,"callId":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":50,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-consumed"},"toolCallId":"cua-paste-consumed","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:103}}"}},"sourceEventSeqs":[259],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":50,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:102}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890781,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890781,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032890781,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890781,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":50,"callId":"cua-paste-activate","name":"cua","arguments":"{\"method\":\"activate\",\"params\":{\"appHandle\":\"fixture-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":50,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-activate"},"toolCallId":"cua-paste-activate","content":[{"type":"text","text":"{\"dispatched\":true,\"foregroundChanged\":true,\"foreground\":{\"requestId\":\"cua-paste-activate\",\"targetPid\":9,\"before\":{\"frontPid\":42,\"focusedWindowId\":7,\"frontWindowId\":99,\"frontWindowPid\":42},\"after\":{\"frontPid\":9,\"focusedWindowId\":7,\"frontWindowId\":7,\"frontWindowPid\":9},\"activationRequested\":true}}"}],"isError":false,"id":"{{message:103}}"}},"sourceEventSeqs":[259],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":50}} {"type":"step/start","data":{"turn":1,"step":51}} -{"type":"assistant/message","data":{"turn":1,"step":51,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:104}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733340,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733340,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733340,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733340,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":51,"callId":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":51,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-verify"},"toolCallId":"cua-paste-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-14\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"fixture paste\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:105}}"}},"sourceEventSeqs":[264],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":51,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:104}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890797,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890797,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890797,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890797,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":51,"callId":"cua-paste-focus","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":51,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-focus"},"toolCallId":"cua-paste-focus","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-13\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:105}}"}},"sourceEventSeqs":[264],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":51}} {"type":"step/start","data":{"turn":1,"step":52}} -{"type":"assistant/message","data":{"turn":1,"step":52,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:106}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733361,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733361,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}}},{"type":"chunk","time":1793949733361,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733361,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":52,"callId":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":52,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-arm"},"toolCallId":"cua-debug-arm","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:107}}"}},"sourceEventSeqs":[269],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":52,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:106}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890813,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890813,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}}},{"type":"chunk","time":1794032890813,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890813,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":52,"callId":"cua-paste-consumed","name":"cua","arguments":"{\"method\":\"paste\",\"params\":{\"appHandle\":\"fixture-app\",\"text\":\"fixture paste\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":52,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-consumed"},"toolCallId":"cua-paste-consumed","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:107}}"}},"sourceEventSeqs":[269],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":52}} {"type":"step/start","data":{"turn":1,"step":53}} -{"type":"assistant/message","data":{"turn":1,"step":53,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:108}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733379,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733379,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1793949733379,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733379,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":53,"callId":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} -{"type":"tool/result","data":{"turn":1,"step":53,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-screenshot"},"toolCallId":"cua-debug-screenshot","content":[{"type":"text","text":"Error: NATIVE_PROTOCOL: Helper returned an invalid response; acquire fresh application handles\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-debug-screenshot\",\"stage\":\"capture-image.begin\",\"elapsedMs\":1}"}],"isError":true,"id":"{{message:109}}"}},"sourceEventSeqs":[274],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":53,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:108}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890830,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890830,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890830,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890830,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":53,"callId":"cua-paste-verify","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":53,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-paste-verify"},"toolCallId":"cua-paste-verify","content":[{"type":"text","text":"{\"appHandle\":\"fixture-app\",\"windowId\":7,\"snapshotId\":\"fixture-snapshot-14\",\"tree\":\"AX scope=window offsets=UTF-16\\n- [0] AXButton \\\"Increment\\\"\\n- [1] AXTextField valueWritable=true selectionWritable=true value=\\\"fixture paste\\\" placeholder=\\\"Fixture text\\\"\\n- AXStaticText \\\"Count: 4\\\"\\n- [2] AXMenuBarItem \\\"File\\\"\\nMenu contents omitted; request includeMenus=true to expand.\",\"windows\":[{\"windowId\":7,\"appName\":\"Fixture\",\"title\":\"CUA fixture\",\"bounds\":{\"x\":40,\"y\":60,\"width\":320,\"height\":200},\"isOnScreen\":true,\"onCurrentSpace\":true}],\"truncated\":false}"}],"isError":false,"id":"{{message:109}}"}},"sourceEventSeqs":[274],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":53}} {"type":"step/start","data":{"turn":1,"step":54}} -{"type":"assistant/message","data":{"turn":1,"step":54,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:110}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733403,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733403,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}}},{"type":"chunk","time":1793949733403,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733403,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":54,"callId":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":54,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-denied"},"toolCallId":"cua-consent-denied","content":[{"type":"text","text":"Error: APP_ACCESS_DENIED: The user denied access to Denied Fixture. Do not retry or use another tool to bypass this decision."}],"isError":true,"id":"{{message:111}}"}},"sourceEventSeqs":[279],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":54,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:110}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890846,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890846,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}}},{"type":"chunk","time":1794032890846,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890846,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":54,"callId":"cua-debug-arm","name":"cua","arguments":"{\"method\":\"pressKey\",\"params\":{\"appHandle\":\"fixture-app\",\"key\":\"F12\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":54,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-arm"},"toolCallId":"cua-debug-arm","content":[{"type":"text","text":"{\"dispatched\":true}"}],"isError":false,"id":"{{message:111}}"}},"sourceEventSeqs":[279],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":54}} {"type":"step/start","data":{"turn":1,"step":55}} -{"type":"assistant/message","data":{"turn":1,"step":55,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:112}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733577,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733577,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}}},{"type":"chunk","time":1793949733577,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733577,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":55,"callId":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":55,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revocable"},"toolCallId":"cua-consent-revocable","content":[{"type":"text","text":"{\"appHandle\":\"revoked-app\",\"name\":\"Revocable Fixture\",\"running\":true}"}],"isError":false,"id":"{{message:113}}"}},"sourceEventSeqs":[284],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":55,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:112}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890862,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890862,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}}},{"type":"chunk","time":1794032890862,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890862,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":55,"callId":"cua-debug-screenshot","name":"cua","arguments":"{\"method\":\"screenshot\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7}}"}} +{"type":"tool/result","data":{"turn":1,"step":55,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-debug-screenshot"},"toolCallId":"cua-debug-screenshot","content":[{"type":"text","text":"Error: NATIVE_PROTOCOL: Helper returned an invalid response; acquire fresh application handles\nScreenshot diagnostics:\n[cua-screenshot] {\"requestId\":\"cua-debug-screenshot\",\"stage\":\"capture-image.begin\",\"elapsedMs\":1}"}],"isError":true,"id":"{{message:113}}"}},"sourceEventSeqs":[284],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":55}} {"type":"step/start","data":{"turn":1,"step":56}} -{"type":"assistant/message","data":{"turn":1,"step":56,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:114}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733745,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1793949733745,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}}},{"type":"chunk","time":1793949733745,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733745,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":56,"callId":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":56,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revoked"},"toolCallId":"cua-consent-revoked","content":[{"type":"text","text":"Error: APP_ACCESS_REVOKED: Application access was revoked; stop using this application"}],"isError":true,"id":"{{message:115}}"}},"sourceEventSeqs":[289],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":56,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:114}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890881,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890881,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}}},{"type":"chunk","time":1794032890881,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890881,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":56,"callId":"cua-consent-denied","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Denied Fixture\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":56,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-denied"},"toolCallId":"cua-consent-denied","content":[{"type":"text","text":"Error: APP_ACCESS_DENIED: The user denied access to Denied Fixture. Do not retry or use another tool to bypass this decision."}],"isError":true,"id":"{{message:115}}"}},"sourceEventSeqs":[289],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":56}} {"type":"step/start","data":{"turn":1,"step":57}} -{"type":"assistant/message","data":{"turn":1,"step":57,"message":{"role":"assistant","content":[{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:116}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1793949733763,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1793949733763,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}}},{"type":"chunk","time":1793949733763,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1793949733763,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":57,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:116}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032891018,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032891018,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}}},{"type":"chunk","time":1794032891018,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032891018,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":57,"callId":"cua-consent-revocable","name":"cua","arguments":"{\"method\":\"getApp\",\"params\":{\"selector\":\"Revocable Fixture\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":57,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revocable"},"toolCallId":"cua-consent-revocable","content":[{"type":"text","text":"{\"appHandle\":\"revoked-app\",\"name\":\"Revocable Fixture\",\"running\":true}"}],"isError":false,"id":"{{message:117}}"}},"sourceEventSeqs":[294],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":57}} +{"type":"step/start","data":{"turn":1,"step":58}} +{"type":"assistant/message","data":{"turn":1,"step":58,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:118}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032891165,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032891165,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}}},{"type":"chunk","time":1794032891165,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032891165,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":58,"callId":"cua-consent-revoked","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"revoked-app\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":58,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-consent-revoked"},"toolCallId":"cua-consent-revoked","content":[{"type":"text","text":"Error: APP_ACCESS_REVOKED: Application access was revoked; stop using this application"}],"isError":true,"id":"{{message:119}}"}},"sourceEventSeqs":[299],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":58}} +{"type":"step/start","data":{"turn":1,"step":59}} +{"type":"assistant/message","data":{"turn":1,"step":59,"message":{"role":"assistant","content":[{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:120}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032891180,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1794032891180,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Observed Count: 4 after hovering, observing and clicking the revealed control. Moving away dismissed the hover information. Geometry conflicts rejected capture and coordinate gestures; the advertised AX button action remained available. No automatic activation or input retry was requested."}}},{"type":"chunk","time":1794032891180,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032891180,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":59}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 0b9c1b29f6..e7132c8c0f 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -43,6 +43,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -67,7 +68,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/cua-native/tool-schemas.expected.json b/snapshots/session/cua-native/tool-schemas.expected.json index 33f3636750..ed8a14779d 100644 --- a/snapshots/session/cua-native/tool-schemas.expected.json +++ b/snapshots/session/cua-native/tool-schemas.expected.json @@ -1413,6 +1413,25 @@ "params" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "requestMouseControl" + }, + "params": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": [ + "method", + "params" + ], + "additionalProperties": false } ], "$defs": { diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 587436227a..45154f9179 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -45,6 +45,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -69,7 +70,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index dd88fa8d9e..b1769c75dc 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -45,6 +45,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -69,7 +70,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. From f11daf3ad3d50769b8d07416473a5537dcb9b966 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 14:24:37 +0800 Subject: [PATCH 37/81] docs(forge): document review thread reads, the thread overlay, and the unsupported failure The forge subsystem page documents ctx.forge.readReview, its thread types, and the panel's changeRequestReview cache; the package READMEs describe the GitHub thread mapping, the overlay and thread list, and the new panel Config fields. An upgrade guide covers the unsupported ForgeFailure code. The Web e2e overlays scripted threads, opens them from the thread list, and sends a quoted thread through a replayed turn. --- .../review-thread-list.expected.md | 17 +++ .../review-thread-message.expected.md | 19 +++ .../forge-panel/review-threads.expected.md | 82 +++++++++++ apps/web/tests/forge-panel.e2e.ts | 90 +++++++++++- docs/config-catalog.md | 16 +- docs/config-catalog.zh.md | 16 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/forge.i18n.yaml | 7 +- docs/subsystems/forge.md | 138 +++++++++++++++++- docs/subsystems/forge.zh.md | 138 +++++++++++++++++- .../forge-review-reads/guide.i18n.yaml | 16 ++ .../v0.2.2-rc.1/forge-review-reads/guide.md | 19 +++ .../forge-review-reads/guide.zh.md | 19 +++ .../client-ui-forge/README.i18n.yaml | 16 +- .../experimental/client-ui-forge/README.md | 13 +- .../experimental/client-ui-forge/README.zh.md | 13 +- .../client-ui-forge/src/client/ChangesTab.tsx | 2 +- .../src/client/ReviewThreads.tsx | 6 +- .../src/client/review-threads.ts | 4 +- .../tests/changes-tab.client.spec.tsx | 7 + .../tests/review-thread-cards.client.spec.tsx | 2 +- .../tests/review-threads.client.spec.ts | 4 +- .../forge-github/README.i18n.yaml | 12 +- packages/experimental/forge-github/README.md | 7 +- .../experimental/forge-github/README.zh.md | 7 +- .../experimental/forge-github/src/index.ts | 7 +- .../forge-github/tests/threads.spec.ts | 15 +- packages/experimental/forge/README.i18n.yaml | 4 +- packages/experimental/forge/README.md | 2 + packages/experimental/forge/README.zh.md | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 46 +++++- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 35 +++++ 34 files changed, 727 insertions(+), 59 deletions(-) create mode 100644 apps/web/tests/expected/forge-panel/review-thread-list.expected.md create mode 100644 apps/web/tests/expected/forge-panel/review-thread-message.expected.md create mode 100644 apps/web/tests/expected/forge-panel/review-threads.expected.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md diff --git a/apps/web/tests/expected/forge-panel/review-thread-list.expected.md b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md new file mode 100644 index 0000000000..0a2d0acbd7 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md @@ -0,0 +1,17 @@ +- complementary "讨论列表,共 5 个": + - text: 讨论 5 + - button "关闭讨论列表" + - group "筛选讨论": + - button "未解决 4" + - button "已过期 1" + - button "已解决 1" + - button "需要我回复 0" + - heading "panel.md" [level=3] + - button "文件级讨论 Add a screenshot." + - heading "panel.ts" [level=3] + - button "R2 · 1 条回复 Why two?" + - button "L15 The old fifteen was a number." + - button "R15 Settled. 已解决" + - button "原在 R9 · 在 fffffff 上 This line moved. 已过期" + - text: github.com 上共 5 个讨论 · + - button "在 GitHub 中查看" diff --git a/apps/web/tests/expected/forge-panel/review-thread-message.expected.md b/apps/web/tests/expected/forge-panel/review-thread-message.expected.md new file mode 100644 index 0000000000..0ea820a2af --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-thread-message.expected.md @@ -0,0 +1,19 @@ +> ## Review Thread 1 +> File: src/panel.ts +> Side: R +> Lines: 2 +> Commit: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +> State: unresolved +> Source: https://github.com/octo/demo/pull/7#discussion_on-line_0 +> Diff hunk: +> ```diff +> @@ -1,5 +1,5 @@ +> export const n1 = 1 +> -export const n2 = 2 +> +export const n2 = 'two' +> ``` +> Comments: +> @lin (2026-10-09T00:00:00Z): +> > Why **two**? +> @octo (2026-10-09T00:00:00Z): +> > It reads better. diff --git a/apps/web/tests/expected/forge-panel/review-threads.expected.md b/apps/web/tests/expected/forge-panel/review-threads.expected.md new file mode 100644 index 0000000000..9b229274bd --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-threads.expected.md @@ -0,0 +1,82 @@ +- button "收起 panel.ts" [expanded] +- text: src/panel.ts +- button "panel.ts 有 3 条未解决的讨论,打开讨论列表": 3 条未解决 +- text: +2 −2 +- checkbox "将 panel.ts 标记为已审阅" +- button "panel.ts 的更多操作" +- button "选择第 L1 行": "1" +- text: export const n1 = 1 +- button "选择第 R1 行": "1" +- text: export const n1 = 1 +- button "选择第 L2 行": "2" +- text: export const n2 = 2 +- button "选择第 R2 行": "2" +- text: export const n2 = 'two' +- region "R2": + - text: R2 · 在 aaaaaaa 上 lin 2 小时前 + - paragraph: + - text: Why + - strong: two + - text: "?" + - text: octo 2 小时前 作者 + - paragraph: It reads better. + - button "添加到输入框" + - button "在 GitHub 中查看" +- button "选择第 L3 行": "3" +- text: export const n3 = 3 +- button "选择第 R3 行": "3" +- text: export const n3 = 3 +- button "选择第 L4 行": "4" +- text: export const n4 = 4 +- button "选择第 R4 行": "4" +- text: export const n4 = 4 +- button "选择第 L5 行": "5" +- text: export const n5 = 5 +- button "选择第 R5 行": "5" +- text: export const n5 = 5 +- button "6 行未变更" +- button "选择第 L12 行": "12" +- text: export const n12 = 12 +- button "选择第 R12 行": "12" +- text: export const n12 = 12 +- button "选择第 L13 行": "13" +- text: export const n13 = 13 +- button "选择第 R13 行": "13" +- text: export const n13 = 13 +- button "选择第 L14 行": "14" +- text: export const n14 = 14 +- button "选择第 R14 行": "14" +- text: export const n14 = 14 +- button "选择第 L15 行": "15" +- text: export const n15 = 15 +- button "选择第 R15 行": "15" +- text: export const n15 = 'fifteen' +- region "L15": + - text: L15 · 在 aaaaaaa 上 lin 2 小时前 + - paragraph: The old fifteen was a number. + - button "添加到输入框" + - button "在 GitHub 中查看" +- text: 已解决的讨论 · lin +- button "展开" +- button "选择第 L16 行": "16" +- text: export const n16 = 16 +- button "选择第 R16 行": "16" +- text: export const n16 = 16 +- button "选择第 L17 行": "17" +- text: export const n17 = 17 +- button "选择第 R17 行": "17" +- text: export const n17 = 17 +- button "选择第 L18 行": "18" +- text: export const n18 = 18 +- button "选择第 R18 行": "18" +- text: export const n18 = 18 +- button "其余未变更的行" +- text: 1 条讨论已过期:所在代码已被后续提交修改 +- button "收起" [expanded] +- region "原在 R9": + - text: 原在 R9 · 在 fffffff 上 已过期 + - button "查看当时的文件" + - text: "@@ -1,5 +1,5 @@ export const n1 = 1 -export const n2 = 2 +export const n2 = 'two' lin 2 小时前" + - paragraph: This line moved. + - button "添加到输入框" + - button "在 GitHub 中查看" diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index f66c449b52..1b0c0bc8fb 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -49,8 +49,9 @@ const run = promisify(execFile) * `pr-N.diff`, or, when `pr-N.refused` exists, fails with that JSON error body * and the matching diagnostic as GitHub's HTTP 406 refusal. The raw contents API * serves `contents.json`, keyed by `path@commit`, and the commit listing of pull - * request 7 serves `commits.json`, oldest first. Every invocation is appended to - * `calls.jsonl`. + * request 7 serves `commits.json`, oldest first. The review thread read serves + * `threads.json` as its one page of threads, with the viewer `octo`, or no threads + * without it. Every invocation is appended to `calls.jsonl`. */ const GH = `#!/usr/bin/env node import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs' @@ -171,6 +172,13 @@ if (args[0] === 'api' && args[1] === 'graphql') { } } } } })) process.exit(0) } + if (query.includes('viewer { login }')) { + const number = Number(args.find(arg => arg.startsWith('number=')).slice('number='.length)) + const threads = existsSync(join(here, 'threads.json')) ? JSON.parse(readFileSync(join(here, 'threads.json'), 'utf8')) : [] + process.stdout.write(JSON.stringify({ data: { viewer: { login: 'octo' }, repository: { pullRequest: { id: 'pull-' + number, + headRefOid: pulls[number].headRefOid, reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: threads } } } } })) + process.exit(0) + } if (query.includes('pullRequest(number: $number)')) { const number = Number(args.find(arg => arg.startsWith('number=')).slice('number='.length)) process.stdout.write(JSON.stringify({ data: { repository: { pullRequest: { headRefOid: pulls[number].headRefOid, baseRefOid: BASE } } } })) @@ -1844,3 +1852,81 @@ it.skipIf(MODE === 'record')('keeps local viewed marks across reloads, clears a await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }) + +/** One review thread node of pull request 7 as the GraphQL thread read returns it, its comments by `lin` then `octo`. */ +function threadNode( + id: string, fields: Record, bodies: readonly string[], commit: string = HEAD7, +): Record { + return { + id, isOutdated: false, isResolved: false, path: 'src/panel.ts', diffSide: 'RIGHT', startDiffSide: null, line: null, startLine: null, + originalLine: null, originalStartLine: null, subjectType: 'LINE', ...fields, + root: { nodes: [{ diffHunk: '@@ -1,5 +1,5 @@\n export const n1 = 1\n-export const n2 = 2\n+export const n2 = \'two\'', + commit: { oid: commit }, originalCommit: { oid: commit } }] }, + comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: bodies.map((body, index) => ({ + id: `${id}-${index}`, state: 'SUBMITTED', createdAt: '2026-10-09T00:00:00Z', url: `${PULL}#discussion_${id}_${index}`, body, + author: { __typename: 'User', login: index === 0 ? 'lin' : 'octo', avatarUrl: null }, + })) }, + } +} + +/** Threads of pull request 7: on new line 2, on old line 15, resolved on new line 15, outdated, and on the whole of `docs/panel.md`. */ +const THREADS = [ + threadNode('on-line', { line: 2, originalLine: 2 }, ['Why **two**?', 'It reads better.']), + threadNode('old-side', { diffSide: 'LEFT', line: 15, originalLine: 15 }, ['The old fifteen was a number.']), + threadNode('resolved', { isResolved: true, line: 15, originalLine: 15 }, ['Settled.']), + threadNode('outdated', { isOutdated: true, originalLine: 9 }, ['This line moved.'], 'f'.repeat(40)), + threadNode('whole-file', { path: 'docs/panel.md', subjectType: 'FILE' }, ['Add a screenshot.']), +] + +it.skipIf(MODE === 'record')('overlays the review threads of a change request and opens them from the thread list', async () => { + const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-review-threads', 'follow-up') + await writeFile(join(root, 'threads.json'), JSON.stringify(THREADS)) + // Comments were written two hours before the browser's clock, so their relative times stay fixed. + await page.clock.setFixedTime(Date.parse('2026-10-09T02:00:00Z')) + const { view, sessionId } = await openReview(page, scaffold) + const panel = view.locator('[data-forge-diff-file="src/panel.ts"]') + const onLine = panel.locator('[data-forge-thread="on-line"]') + await onLine.getByText('two', { exact: true }).waitFor({ timeout: 20_000 }) + // The thread follows the row of its last line, under the new side. + expect(await onLine.evaluate(card => card.previousElementSibling?.querySelector('[data-new-line]')?.textContent)).toBe('export const n2 = \'two\'') + expect(await panel.locator('[data-forge-thread="old-side"]').evaluate(card => card.previousElementSibling?.querySelector('[data-old-line]')?.textContent)) + .toBe('export const n15 = 15') + await panel.locator('[data-forge-thread="resolved"]').getByText('已解决的讨论 · lin', { exact: true }).waitFor() + await panel.locator('[data-forge-outdated-threads]').getByText('1 条讨论已过期:所在代码已被后续提交修改', { exact: true }).waitFor() + await view.locator('[data-forge-diff-file="docs/panel.md"] [data-forge-thread="whole-file"]').getByText('文件级讨论', { exact: true }).waitFor() + await panel.getByRole('button', { name: 'panel.ts 有 3 条未解决的讨论,打开讨论列表', exact: true }).waitFor() + await view.getByRole('navigation').getByTitle('3 条未解决的讨论', { exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'review-threads.expected.md'), + await captureStableAria(page, '[data-forge-diff-file="src/panel.ts"]', scaffold.workspaceCwd), MODE) + + await view.getByRole('button', { name: '讨论列表,共 5 个', exact: true }).click() + const list = view.getByRole('complementary', { name: '讨论列表,共 5 个' }) + await expect.poll(() => list.getByRole('button', { pressed: true }).allTextContents()).toEqual(['未解决 4']) + await expect.poll(() => list.locator('[data-forge-thread-item]').evaluateAll(items => items.map(item => item.getAttribute('data-forge-thread-item')))) + .toEqual(['whole-file', 'on-line', 'old-side', 'outdated']) + await list.getByRole('button', { name: '已解决 1', exact: true }).click() + await list.locator('[data-forge-thread-item="resolved"]').click() + // Opening a resolved thread unfolds it inside its file. + await panel.locator('[data-forge-thread="resolved"]').getByText('Settled.', { exact: true }).waitFor() + await list.getByRole('button', { name: '已解决 1', exact: true }).click() + await list.getByRole('button', { name: '需要我回复 0', exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'review-thread-list.expected.md'), + await captureStableAria(page, '[data-forge-thread-list]', scaffold.workspaceCwd), MODE) + + await onLine.getByRole('button', { name: '添加到输入框', exact: true }).click() + await page.locator('[data-composer-input]').first().getByText('panel.ts R2', { exact: true }).first().waitFor() + const sent = scaffold.whenTurnSettled() + await page.locator('[data-composer-input]').first().press('Enter') + await sent + await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor() + // The quoted thread reaches the model only inside the ordinary logged user message. + const quotes = (await readPersistedEvents(scaffold, sessionId)).flatMap(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' && block.text.includes('## Review Thread') ? [block.text] : []) : []) + expect(quotes).toHaveLength(1) + await compareOrRefreshGolden(join(EXPECTED, 'review-thread-message.expected.md'), quotes.join('').trimEnd(), MODE) + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).split('\n').filter(line => line.length > 0).map(line => JSON.parse(line) as string[]) + const reads = calls.filter(args => args.some(arg => arg.startsWith('query=') && arg.includes('viewer { login }'))) + expect(reads.length).toBeGreaterThan(0) + expect(reads[0]).toEqual(expect.arrayContaining(['owner=octo', 'name=demo', 'number=7', 'first=100'])) + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d85683e33e..e68110b349 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1121,6 +1121,13 @@ export interface Config { * `testPaths` and before the built-in rules. */ interfacePaths?: string[] + /** Most UTF-8 bytes, as JSON, of change request review threads kept in memory for later reads at the same head commit and update time. */ + maxCachedReviewBytes?: number + /** + * Age in milliseconds at which review threads kept in memory are read from the code host again; a GitHub pull + * request's update time does not change when a thread is resolved. + */ + reviewRefreshMs?: number } ``` @@ -1188,7 +1195,7 @@ export type SourceSigning = 'ad-hoc' | { ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:359`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1222,9 +1229,12 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read or review read. */ maxOutputBytes?: number - /** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */ + /** + * Most review threads counted per pull request in one status read, which reports a lower bound past it, and read in + * one review read, which reports itself truncated past it. + */ maxReviewThreads?: number /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ maxCommits?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ceab76dfec..1956f19ac1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1123,6 +1123,13 @@ export interface Config { * `testPaths` and before the built-in rules. */ interfacePaths?: string[] + /** Most UTF-8 bytes, as JSON, of change request review threads kept in memory for later reads at the same head commit and update time. */ + maxCachedReviewBytes?: number + /** + * Age in milliseconds at which review threads kept in memory are read from the code host again; a GitHub pull + * request's update time does not change when a thread is resolved. + */ + reviewRefreshMs?: number } ``` @@ -1190,7 +1197,7 @@ export type SourceSigning = 'ad-hoc' | { ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:359`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1224,9 +1231,12 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read or review read. */ maxOutputBytes?: number - /** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */ + /** + * Most review threads counted per pull request in one status read, which reports a lower bound past it, and read in + * one review read, which reports itself truncated past it. + */ maxReviewThreads?: number /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ maxCommits?: number diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b4cffaf1aa..d157809e13 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -47,7 +47,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:29`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | -| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:28`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | | `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b1b58ef08a..194d956a4f 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -49,7 +49,7 @@ | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:29`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | -| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:28`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | | `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml index 97ebf7bece..1407e29d9d 100644 --- a/docs/subsystems/forge.i18n.yaml +++ b/docs/subsystems/forge.i18n.yaml @@ -14,9 +14,12 @@ /forges/change-request-actions: en: 87d2c81d9514267f zh: 1daf31c5b0127a1c +/forges/review-threads: + en: 351af1e634f70a40 + zh: 2f0bba65d3b01277 /forges/session-bindings: en: 13dfd36f2166bba0 zh: a369434f52f04d57 /forges/panel: - en: e52a5c57abc4d30e - zh: 509a40f421cc3d33 + en: 11b157e9debe81cd + zh: b197644deabe727d diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index b5156c101f..0a632c5de1 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -47,6 +47,12 @@ interface ForgeProvider { readonly forge: string /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** + * Read the review threads of a change request at head commit `headSha`. The threads are returned only when the + * read names `headSha` as the head; once the head is another commit, the read resolves to a `stale` failure. + * Omission declares that this provider cannot read reviews. + */ + readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ @@ -238,9 +244,9 @@ interface ForgeFailure { * `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; * `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails * the same way; `stale`: the head is no longer the commit the read named, so reading again at the head of - * a later status can succeed; `unavailable`: any other failure. + * a later status can succeed; `unsupported`: the provider does not offer the read; `unavailable`: any other failure. */ - readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable' + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unsupported' | 'unavailable' /** Human-readable cause. */ readonly message: string } @@ -328,6 +334,104 @@ type ForgeReviewResult = | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } ``` +## Review threads + +`ctx.forge.readReview(ref, headSha, signal)` reads the review threads of a change request at one head commit. The provider returns `ForgeReviewData` only when the read names `headSha` as the head; otherwise it returns `stale`, which polls a watched change request again at once, as a stale diff read does. For a provider without `readReview` the service returns `unsupported`, so consumers show no threads instead of a failure. A line thread has two positions: `original` on the commit it was written on, and `current` on the head of the read, which is null once the host no longer places the thread on that commit. A consumer that shows the head's diff places a thread at `current` and shows the others with their `diffHunk`; reading the file at `original.commit` through `fileText` shows the whole file the thread was written on. The [GitHub provider README](../../packages/experimental/forge-github/README.md#use-this-package) owns how GitHub's fields map onto these positions. + +```ts type-equiv +/** Opaque host identity of one review thread. */ +type ForgeThreadId = Branded<'ForgeThreadId'> +``` + +```ts type-equiv +/** Opaque host identity of one review comment. */ +type ForgeCommentId = Branded<'ForgeCommentId'> +``` + +```ts type-equiv +/** Consecutive lines of one file at one commit. */ +interface ForgeAnchor { + /** Full object name of the commit whose file the lines number. */ + readonly commit: string + /** First line, inclusive. */ + readonly startLine: number + /** Last line, inclusive. */ + readonly endLine: number +} +``` + +```ts type-equiv +/** Account that wrote a review comment. */ +interface ForgeCommentAuthor { + /** Login as the host spells it. */ + readonly login: string + /** Avatar image URL, or null when the host reports none. */ + readonly avatarUrl: string | null + /** Whether the account is an app or bot rather than a person. */ + readonly bot: boolean +} +``` + +```ts type-equiv +/** One comment of a review thread. */ +interface ForgeReviewComment { + /** Host identity of the comment. */ + readonly id: ForgeCommentId + /** Author, or null when the account no longer exists. */ + readonly author: ForgeCommentAuthor | null + /** Markdown source as the author wrote it; it may contain raw HTML, which readers must not render as HTML. */ + readonly body: string + /** ISO 8601 time the comment was created. */ + readonly createdAt: string + /** Whether the comment belongs to the signed-in user's review that is not submitted yet, which only that user sees. */ + readonly pending: boolean + /** Web page of the comment, or null when the host reports none. */ + readonly url: string | null +} +``` + +```ts type-equiv +/** + * One review thread on a file of a change request. A line thread has two positions: `original` on the commit it was + * written on, and `current` on the head commit of the read. `current` is null once the host no longer places the + * thread on the head commit, as after later commits changed its lines. + */ +interface ForgeReviewThread { + /** Host identity of the thread. */ + readonly id: ForgeThreadId + /** Repository-relative path the thread is on. */ + readonly path: string + /** Old (`L`) or new (`R`) side of the diff the lines number. */ + readonly side: 'L' | 'R' + /** Whether the thread is on lines or on the whole file. */ + readonly subject: 'line' | 'file' + /** Lines at the head commit of the read, whose `commit` is that head; null for an outdated or file thread. */ + readonly current: ForgeAnchor | null + /** Lines at the commit the thread was written on; null for a file thread. */ + readonly original: ForgeAnchor | null + /** Diff hunk the host recorded when the thread was written, ending at its last line; null when it recorded none. */ + readonly diffHunk: string | null + /** Whether the thread is marked resolved. */ + readonly resolved: boolean + /** Comments oldest first; the first starts the thread. */ + readonly comments: readonly ForgeReviewComment[] +} +``` + +```ts type-equiv +/** Review threads of a change request read at one head commit. */ +interface ForgeReviewData { + /** Head commit the threads were read at; `current` positions number its files. */ + readonly headSha: string + /** Login of the signed-in user the provider read as, or null when the host does not name one. */ + readonly viewer: string | null + /** Threads in host order, at most the provider's limit. */ + readonly threads: readonly ForgeReviewThread[] + /** Whether the change request has more threads than `threads` holds. */ + readonly truncated: boolean +} +``` + ## Session bindings `ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results trigger head-branch discovery through `ctx.forge`; the panel also reports the branch its checkout tracks for the same discovery. Track, dismiss, and discovery actions are `plugin:forge-session/binding` records, which stay outside the model-visible conversation. The `forgeChangeRequests` projection's client view, which the Session summary carries, lists the bindings without their status. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules. @@ -617,6 +721,8 @@ type ForgeChangeRequestStatesFrame = The changes view reads through the same Remote. `localDiff` reads one `ForgeLocalScope` of the Session's checkout, and `changeRequestDiff` reads a bound change request at the head of its latest status through the provider's `diff`, which returns a patch only for that head commit, or the `ForgeCommitRange` it names through the provider's `compare`; both return files that the Host parsed into `ForgeDiffFile` hunks, each with its review layer and change digest. `localCommits` and `changeRequestCommits` list the commits a range can name, the latter as null when the provider does not read commits. `localText` and `changeRequestText` read the new side of one file, which supplies the unchanged lines between hunks. Each inspection also sizes the `branch` scope as `ForgePanelState.local` for the panel's local changes card, always against the automatic fork point. A `branch` scope whose `base` names a ref compares with the merge base of HEAD and that branch instead; `diffBases` lists the branches the view offers, with the automatic fork point. When the head moved past the latest status, the provider returns `stale` and `ctx.forge.diff` polls the change request at once, once per head commit; `changeRequestDiff` rejects with `forgePanel/stale-head` and caches nothing, and the changes view keeps loading until a later status arrives, then reads again at that status's head. Review comments therefore always address the commit whose lines the view shows. +`changeRequestReview(session, url, headSha)` reads the threads of a bound change request at the head of the diff the view shows and refuses another head with `forgePanel/stale-head`, `forgePanel/review-unsupported` for a provider without review reads, and `forgePanel/review-failed` otherwise. It keeps one read per change request in memory, keyed by head commit and the status's update time and bounded by `maxCachedReviewBytes`, and reads again once the kept read is `reviewRefreshMs` old: on GitHub a new comment or reply changes a pull request's update time, but resolving a thread does not. The changes view asks again at every status it receives while visible, and overlays the threads on the files the [panel README](../../packages/experimental/client-ui-forge/README.md#use-this-package) describes. + ```ts type-equiv /** * Consecutive commits a changes view shows instead of its whole comparison, named by full object names in the order @@ -916,6 +1022,17 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Read the review threads of a change request at one head commit. A + * `stale` failure polls a watched change request again as {@link ForgeService.diff} does. + * @param ref - change request served by a registered provider. + * @param headSha - head commit the threads must be read at, usually the head of the displayed diff. + * @param signal - cancellation of the read. + * @returns the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + /** * List the commits of a change request at its current head. * @param ref - change request served by a registered provider. @@ -1186,6 +1303,23 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise +/** + * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read + * at the same head commit and update time are reused from memory, for any Session, until they are + * `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved + * since, which do not change a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param headSha - head commit of the displayed diff. + * @param signal - request cancellation. + * @returns the threads at `headSha`, with `current` positions numbering the files of that diff. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; + * `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the + * status was read and when the host does not provide the threads otherwise. + */ +@Remote async changeRequestReview(session: Session, url: string, headSha: string, signal: AbortSignal): Promise + /** * Publish a line comment or completed review for a visible Session binding. * @param session - Session retaining the change request. diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 2ca42cde71..3c4459101f 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -47,6 +47,12 @@ interface ForgeProvider { readonly forge: string /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** + * Read the review threads of a change request at head commit `headSha`. The threads are returned only when the + * read names `headSha` as the head; once the head is another commit, the read resolves to a `stale` failure. + * Omission declares that this provider cannot read reviews. + */ + readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ @@ -238,9 +244,9 @@ interface ForgeFailure { * `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; * `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails * the same way; `stale`: the head is no longer the commit the read named, so reading again at the head of - * a later status can succeed; `unavailable`: any other failure. + * a later status can succeed; `unsupported`: the provider does not offer the read; `unavailable`: any other failure. */ - readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable' + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unsupported' | 'unavailable' /** Human-readable cause. */ readonly message: string } @@ -328,6 +334,104 @@ type ForgeReviewResult = | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } ``` +## 评审讨论 + +`ctx.forge.readReview(ref, headSha, signal)` 读取变更请求在某个 head 提交处的评审讨论。只有当读取时的 head 就是 `headSha` 时,提供方才返回 `ForgeReviewData`;否则返回 `stale`,并像 diff 读取遇到 `stale` 时一样,立即重新轮询被关注的变更请求。提供方没有实现 `readReview` 时,服务返回 `unsupported`,使用方据此不显示讨论,也不报错。行级讨论有两套位置:`original` 是写下讨论时所在提交上的行,`current` 是本次读取的 head 上的行;托管平台不再把讨论放在该提交上时,`current` 为 null。显示 head diff 的使用方把讨论放在 `current` 位置,其余讨论用 `diffHunk` 展示;用 `fileText` 读取 `original.commit` 上的文件,可以看到写下讨论时的完整文件。GitHub 各字段如何映射到这两套位置,由 [GitHub 提供方 README](../../packages/experimental/forge-github/README.zh.md#use-this-package) 说明。 + +```ts type-equiv +/** Opaque host identity of one review thread. */ +type ForgeThreadId = Branded<'ForgeThreadId'> +``` + +```ts type-equiv +/** Opaque host identity of one review comment. */ +type ForgeCommentId = Branded<'ForgeCommentId'> +``` + +```ts type-equiv +/** Consecutive lines of one file at one commit. */ +interface ForgeAnchor { + /** Full object name of the commit whose file the lines number. */ + readonly commit: string + /** First line, inclusive. */ + readonly startLine: number + /** Last line, inclusive. */ + readonly endLine: number +} +``` + +```ts type-equiv +/** Account that wrote a review comment. */ +interface ForgeCommentAuthor { + /** Login as the host spells it. */ + readonly login: string + /** Avatar image URL, or null when the host reports none. */ + readonly avatarUrl: string | null + /** Whether the account is an app or bot rather than a person. */ + readonly bot: boolean +} +``` + +```ts type-equiv +/** One comment of a review thread. */ +interface ForgeReviewComment { + /** Host identity of the comment. */ + readonly id: ForgeCommentId + /** Author, or null when the account no longer exists. */ + readonly author: ForgeCommentAuthor | null + /** Markdown source as the author wrote it; it may contain raw HTML, which readers must not render as HTML. */ + readonly body: string + /** ISO 8601 time the comment was created. */ + readonly createdAt: string + /** Whether the comment belongs to the signed-in user's review that is not submitted yet, which only that user sees. */ + readonly pending: boolean + /** Web page of the comment, or null when the host reports none. */ + readonly url: string | null +} +``` + +```ts type-equiv +/** + * One review thread on a file of a change request. A line thread has two positions: `original` on the commit it was + * written on, and `current` on the head commit of the read. `current` is null once the host no longer places the + * thread on the head commit, as after later commits changed its lines. + */ +interface ForgeReviewThread { + /** Host identity of the thread. */ + readonly id: ForgeThreadId + /** Repository-relative path the thread is on. */ + readonly path: string + /** Old (`L`) or new (`R`) side of the diff the lines number. */ + readonly side: 'L' | 'R' + /** Whether the thread is on lines or on the whole file. */ + readonly subject: 'line' | 'file' + /** Lines at the head commit of the read, whose `commit` is that head; null for an outdated or file thread. */ + readonly current: ForgeAnchor | null + /** Lines at the commit the thread was written on; null for a file thread. */ + readonly original: ForgeAnchor | null + /** Diff hunk the host recorded when the thread was written, ending at its last line; null when it recorded none. */ + readonly diffHunk: string | null + /** Whether the thread is marked resolved. */ + readonly resolved: boolean + /** Comments oldest first; the first starts the thread. */ + readonly comments: readonly ForgeReviewComment[] +} +``` + +```ts type-equiv +/** Review threads of a change request read at one head commit. */ +interface ForgeReviewData { + /** Head commit the threads were read at; `current` positions number its files. */ + readonly headSha: string + /** Login of the signed-in user the provider read as, or null when the host does not name one. */ + readonly viewer: string | null + /** Threads in host order, at most the provider's limit. */ + readonly threads: readonly ForgeReviewThread[] + /** Whether the change request has more threads than `threads` holds. */ + readonly truncated: boolean +} +``` + ## Session 绑定 `ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现;面板也会报告其检出所跟踪的分支,触发同样的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。`forgeChangeRequests` 投影的客户端视图随 Session 摘要下发,列出绑定但不含其状态。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。 @@ -617,6 +721,8 @@ type ForgeChangeRequestStatesFrame = 变更视图经同一个 Remote 读取。`localDiff` 读取 Session 检出的某个 `ForgeLocalScope`;`changeRequestDiff` 经提供方的 `diff` 读取已绑定变更请求在其最新状态 head 处的变更(提供方只返回描述该 head 提交的补丁),或经提供方的 `compare` 读取它指定的 `ForgeCommitRange`;两者返回的文件都已在宿主上解析为 `ForgeDiffFile` 的 hunk,并带有分层与改动摘要。`localCommits` 与 `changeRequestCommits` 列出范围可以指定的提交,提供方不读取提交时后者返回 null。`localText` 与 `changeRequestText` 读取一个文件的新版本,用来补出 hunk 之间未变更的行。每次检查还会把 `branch` 范围的规模统计为 `ForgePanelState.local`,供面板的变更卡片使用,始终以自动分叉点为准。`base` 指定了引用的 `branch` 范围改为与 HEAD 和该分支的合并基准比较;`diffBases` 列出视图可选的分支以及自动分叉点。head 已越过最新状态时,提供方返回 `stale`,`ctx.forge.diff` 立即重新轮询该变更请求(每个 head 提交一次);`changeRequestDiff` 以 `forgePanel/stale-head` 拒绝且不缓存任何内容,变更视图保持加载,直到更新的状态到达后按该状态的 head 重新读取。因此评审评论始终指向视图所显示行的那个提交。 +`changeRequestReview(session, url, headSha)` 在视图所显示 diff 的 head 处读取已绑定变更请求的讨论;head 不同时以 `forgePanel/stale-head` 拒绝,提供方不支持读取讨论时返回 `forgePanel/review-unsupported`,其他失败返回 `forgePanel/review-failed`。每个变更请求在内存里保留一次读取结果,以 head 提交和状态的更新时间为键、受 `maxCachedReviewBytes` 约束,保留时间达到 `reviewRefreshMs` 后重新读取:在 GitHub 上,新评论和回复会改变 PR 的更新时间,解决讨论则不会。变更视图可见时,每收到一次状态就重新请求一次,并按[面板 README](../../packages/experimental/client-ui-forge/README.zh.md#use-this-package) 所述把讨论叠加到文件上。 + ```ts type-equiv /** * Consecutive commits a changes view shows instead of its whole comparison, named by full object names in the order @@ -916,6 +1022,17 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Read the review threads of a change request at one head commit. A + * `stale` failure polls a watched change request again as {@link ForgeService.diff} does. + * @param ref - change request served by a registered provider. + * @param headSha - head commit the threads must be read at, usually the head of the displayed diff. + * @param signal - cancellation of the read. + * @returns the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + /** * List the commits of a change request at its current head. * @param ref - change request served by a registered provider. @@ -1186,6 +1303,23 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise +/** + * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read + * at the same head commit and update time are reused from memory, for any Session, until they are + * `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved + * since, which do not change a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param headSha - head commit of the displayed diff. + * @param signal - request cancellation. + * @returns the threads at `headSha`, with `current` positions numbering the files of that diff. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; + * `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the + * status was read and when the host does not provide the threads otherwise. + */ +@Remote async changeRequestReview(session: Session, url: string, headSha: string, signal: AbortSignal): Promise + /** * Publish a line comment or completed review for a visible Session binding. * @param session - Session retaining the change request. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml new file mode 100644 index 0000000000..8377a14f8a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md +/: + en: 53dbdc67362a5851 + zh: 9c7b2c4dd31cf666 +/forge-failures-report-unsupported-reads: + en: 3e5488f3008886b7 + zh: 8123e954c41d559c +/forge-failures-report-unsupported-reads/change: + en: 77712ff12908e838 + zh: 00714f77f3b7ae80 +/forge-failures-report-unsupported-reads/migration: + en: 698a34a7e09be2be + zh: a32a64dc130f496f diff --git a/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md new file mode 100644 index 0000000000..d6c6aa2962 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "ForgeFailure gains the unsupported code, which ctx.forge.readReview returns for a provider without review reads." +--- + +# Forge failures report unsupported reads + +English | [中文](guide.zh.md) + +## Change + +`ForgeFailure['code']` gains `unsupported`. `ctx.forge.readReview(ref, headSha, signal)`, new beside the optional `ForgeProvider.readReview`, returns it when the provider of the change request does not read review threads. Other reads keep their codes: a provider without `fetchChecks` still makes `ctx.forge.fetchChecks` return `unavailable`. + +This affects code that switches exhaustively over `ForgeFailure['code']`, such as a `Record` lookup or a `switch` that ends in `assertNever`. All types come from `@deepseek-ai/dsh-experimental-forge`. Providers need no change: `readReview` is optional. + +## Migration + +1. Handle `unsupported` wherever code switches exhaustively over `ForgeFailure['code']`; treat it as a capability the provider lacks, not as a failed read to retry. +2. Run your type check; it succeeds when no `ForgeFailure` code is unhandled. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md new file mode 100644 index 0000000000..75f8a1057e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "ForgeFailure 新增 unsupported 代码;提供方不支持读取评审讨论时,ctx.forge.readReview 返回它。" +--- + +# Forge 失败代码报告不支持的读取 + +[English](guide.md) | 中文 + +## 变更 + +`ForgeFailure['code']` 新增 `unsupported`。新增的 `ctx.forge.readReview(ref, headSha, signal)` 对应可选的 `ForgeProvider.readReview`;变更请求的提供方不支持读取评审讨论时,它返回 `unsupported`。其他读取的代码不变:提供方未实现 `fetchChecks` 时,`ctx.forge.fetchChecks` 仍返回 `unavailable`。 + +受影响的是对 `ForgeFailure['code']` 做穷举的代码,例如 `Record` 查表,或以 `assertNever` 结尾的 `switch`。所有类型都来自 `@deepseek-ai/dsh-experimental-forge`。提供方无需修改:`readReview` 是可选方法。 + +## 迁移 + +1. 在所有对 `ForgeFailure['code']` 做穷举的地方处理 `unsupported`,把它当作提供方缺少的能力,而不是需要重试的读取失败。 +2. 运行类型检查;没有未处理的 `ForgeFailure` 代码时即通过。 diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 5b25741184..a0392da274 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,23 +15,23 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 05ee6d9ba0b8d2c7 - zh: 98c988e8f957511f + en: e9ecdf9670bd6324 + zh: 6f5e3027afb18a3e /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 7495fe1e0d0ee643 - zh: f820a5790ae6e2a7 + en: 78cab599dbd4aa57 + zh: 87265844f34efbc5 /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b /deepseek-ai-dsh-experimental-client-ui-forge/model-experience: - en: 10101427d1e0589b - zh: c981cec12f64eb75 + en: 795766e162ebf4df + zh: 893de3302798330a /deepseek-ai-dsh-experimental-client-ui-forge/model-experience/kv-cache-effect: en: 2bc25a99e17789c4 zh: 4bec723fbf249d28 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: 5e29ac36aad15d02 - zh: e46339bb637a69ef + en: 70d01fb04317f94a + zh: 6a287a063603f61d /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 27ff8a01c3..190c23e7d0 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -41,12 +41,13 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. - **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits. +- **Review threads** — A change request view overlays the review threads its code host read at the shown head. A thread on lines follows the row of its last line, on the side it was written on, or the collapsed unchanged lines that hold it; it shows the place it was written, each comment's avatar, author, time, and Markdown with raw HTML left out, and tags for the change request's author, bots, and comments still pending in the signed-in user's review. A resolved thread folds into one row that unfolds on request. Threads on a whole file open the file, and threads later commits made outdated gather after its lines in a section that shows the end of the hunk they were written on and offers View file at that commit, which reads the file at the commit they were written on with their lines marked. Chosen commits show only new-side threads whose lines number the last chosen commit. The file tree and each file header count unresolved threads; the count in a header, like the Threads toolbar button, opens the thread list, which groups the threads by file in file tree order, filters Unresolved, Outdated, Resolved, or Awaiting my reply, which keeps unresolved threads the signed-in user wrote in where someone else wrote last, and opens each thread at its place, choosing all commits for one the chosen commits do not hold. The list names a truncated read. A thread offers only Add to input, which quotes the whole thread, and View on GitHub. Threads are read again at each status while the view shows; a failed read keeps the last threads and offers Retry, and a provider that does not read reviews shows none. The checks row initially shows the polling preview. While it is incomplete, the card uses the host’s aggregate outcome without a failure count and labels the loaded check count as partial. Opening the list loads the remaining pages for that PR through `changeRequestChecks`; its deadline is independent of polling. A failed read keeps the preview and offers Retry. Closing the list, changing the head, or unmounting cancels the read; late results are ignored. Reopening reads current details. The Remote requires a visible Session binding and rejects a response for a different head commit. The diff follows the file tree’s depth-first order: directories before files, with each group sorted by displayed name in case-sensitive UTF-16 code-unit order. Compacted directory chains are compared as one complete name. Filtering or folding the tree does not reorder the diff. Click the diff content to focus it. Left and Right move to the previous and next file in diff order and expand it; navigation stops at the first and last file. Up and Down keep their normal scrolling behavior. After manual scrolling, file navigation starts from the file at the top of the viewport. Text editing, non-collapsed text selections, modified keys, menus, and file-tree controls keep their own keyboard behavior. -`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, `maxCachedDiffBytes`, `maxCommits`, `testPaths`, and `interfacePaths` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). +`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, `maxCachedDiffBytes`, `maxCachedReviewBytes`, `reviewRefreshMs`, `maxCommits`, `testPaths`, and `interfacePaths` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). All files opens a searchable directory tree with the selected file scrolled to its middle. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer that shows the file name, side and lines, and comment, and on hover the file path; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. @@ -69,7 +70,7 @@ Restoration records `restoring` before mutation and `restored` only after succes Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. -`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. +`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. `changeRequestReview` acts on the same visible bindings, refuses a head other than the latest status's with `forgePanel/stale-head` before reading, and reads the provider's `readReview`, mapping `stale` to `forgePanel/stale-head`, `unsupported` to `forgePanel/review-unsupported`, and other failures to `forgePanel/review-failed`. It keeps one read per change request, keyed by head commit and update time, drops the least recently read once the kept reads exceed `maxCachedReviewBytes` as JSON, and reads again once a kept read is `reviewRefreshMs` old, since resolving a GitHub thread leaves the update time unchanged. `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. @@ -95,6 +96,11 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | Lens, test file switch, and viewed progress | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | Commit picker | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | Viewed marks kept in the browser | +| [`src/client/ReviewThreads.tsx`](src/client/ReviewThreads.tsx) | Review thread cards and the outdated section | +| [`src/client/ThreadList.tsx`](src/client/ThreadList.tsx) | Thread list | +| [`src/client/review-threads.ts`](src/client/review-threads.ts) | Thread places, list filters, hunk excerpts, and thread quotes | +| [`src/client/thread-state.ts`](src/client/thread-state.ts) | Thread reads and their places in the shown changes | +| [`src/review-cache.ts`](src/review-cache.ts) | Review threads kept in memory | | [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | Changed file label: name, then directory | | [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | New Session worktree option | | [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | Worktree creation and removed-worktree card | @@ -117,7 +123,7 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t ## Model Experience -Indirectly, through the working-directory service and the Session's conversation: the notice about a Session's new worktree joins the first request's messages, Continue in the project queues the same kind of notice for the next request, and Create change request sends a user message that asks the Agent for a change request. Add to input contributes a frozen `## User Comment N` quote containing File, Side, Lines, Source, the diff hunk, and Comment; PR quotes also name the reviewed commit, local Branch quotes the base the view compared with, and quotes from chosen commits name them. These quotes enter the normal logged user message only when the composer is sent. Inspection, status, restoring, the Session list, and GitHub review submission add no model context. +Indirectly, through the working-directory service and the Session's conversation: the notice about a Session's new worktree joins the first request's messages, Continue in the project queues the same kind of notice for the next request, and Create change request sends a user message that asks the Agent for a change request. Add to input contributes a frozen `## User Comment N` quote containing File, Side, Lines, Source, the diff hunk, and Comment; PR quotes also name the reviewed commit, local Branch quotes the base the view compared with, and quotes from chosen commits name them. These quotes enter the normal logged user message only when the composer is sent. Inspection, status, restoring, the Session list, and GitHub review submission add no model context. Add to input on a review thread contributes a frozen `## Review Thread N` quote with File, Side, Lines, the commit those lines number, State, Source, the recorded diff hunk, and every comment's author, time, and body quoted line by line. #### KV Cache effect @@ -140,6 +146,7 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Very many branches** — the base branch menu reads every ref before it filters, so a search can reach any branch. Refs over `maxDiffBytes`, about 165,000 at the default, or a listing over `gitTimeoutMs`, leave the menu saying that branches could not be read; Default keeps working. - **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. - **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them. +- **Review thread actions** — threads are read-only: replying, resolving, and editing comments, and drafts in GitHub's pending review, are deferred to the provider's review writes. Minimized comments show like others, and avatars load from the code host's image server in the browser. - **Review layers** — the built-in rules cover common conventions only, so a repository records others in `.gitattributes`; a path cannot show Rust's inline `#[cfg(test)]` modules or which declarations a language exports, and the Interface layer keeps whole files, so finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros. - **Chosen commits** — a change request lists at most `maxCommits` commits of the provider, the oldest 250 for GitHub, while the Branch scope lists its newest `maxCommits`; a run must follow first parents, so commits of a merged branch can be shown one at a time but not together with the branch's own; a commit range is read from the host on every view; comments on chosen commits can only be added to the input, as a GitHub review addresses the head. - **Review page** — deferred: a left-navigation page that lists the user's change requests from the selected provider; the [decision note](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.md) records where it attaches. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 4aca1d2c38..f7ced4299b 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -41,12 +41,13 @@ kind: "package-reference" - **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 - **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在;同一 Session 的多个标签页共用这些标记:在一个标签页中标记,其他标签页随之更新,且不会覆盖它们的标记。只改文件模式(例如 `chmod +x`)也算新改动。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交,读取一次后保留到 head、本地分支或基准变化或刷新为止;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交,这些提交必须依次是后一个的第一父提交:离开这条链的范围(例如跨越已合并分支的范围)会让菜单保持打开并说明原因;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。 +- **评审讨论**——变更请求视图会叠加代码托管平台在所显示 head 处读到的评审讨论。行级讨论显示在它最后一行所在的行之后,位于写下时所在的那一侧;若该行折叠在未变更的行中,则显示在折叠行之后。讨论标明写下时的位置,每条评论显示头像、作者、时间和 Markdown 正文(不显示原始 HTML),并用标签标出变更请求作者、机器人账号,以及当前登录用户尚未提交的评审中的评论。已解决的讨论折叠成一行,可以展开。针对整个文件的讨论显示在文件开头;被后续提交改到而过期的讨论集中在文件的行之后,显示写下时那段 hunk 的末尾,并提供“查看当时的文件”,读取写下讨论时那个提交上的文件并标出相应的行。选择了部分提交时,只显示行号对应最后一个所选提交的新版本侧讨论。文件树和每个文件标题都显示未解决讨论数;点击文件标题上的计数或工具栏的“讨论”按钮会打开讨论列表。列表按文件树顺序分组,可按未解决、已过期、已解决和“需要我回复”筛选(“需要我回复”保留当前登录用户参与过、且最后一条评论来自他人的未解决讨论),点击讨论会跳到它的位置;若所选提交中没有该讨论,会先切换到全部提交。读取被截断时,列表会注明。讨论上只有“添加到输入框”(把整个讨论作为引用插入)和“在 GitHub 中查看”两个操作。视图显示期间,每收到一次状态就重新读取讨论;读取失败时保留上次的讨论并提供重试,提供方不支持读取讨论时不显示讨论。 检查行先显示轮询预览。不完整时,卡片使用托管平台的总体状态而不显示失败总数,并注明已加载的检查数量尚不完整。打开列表后,经 `changeRequestChecks` 单独读取该 PR 的后续页,时限独立于轮询。读取失败时保留预览并提供重试。关闭列表、head 变化或组件卸载会取消读取,忽略迟到结果;重新打开会读取当前详情。Remote 要求 URL 是该 Session 可见的绑定,并拒绝 head 提交不一致的响应。 diff 按文件树的深度优先顺序排列:同层目录在前、文件在后,各组按显示名称以区分大小写的 UTF-16 码元顺序排序。压缩目录链作为一个完整名称参与比较。筛选或折叠文件树不会改变 diff 的顺序。点击 diff 内容区可将焦点移入。左右方向键按 diff 顺序跳到上一个、下一个文件并展开它;到达首尾时停留。上下方向键保留原有滚动行为。手动滚动后,文件跳转从视口顶部的文件继续。文本编辑、非折叠文本选区、组合键、菜单与文件树控件保留各自的键盘行为。 -`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles`、`maxCachedDiffBytes`、`maxCommits`、`testPaths` 与 `interfacePaths` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 +`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles`、`maxCachedDiffBytes`、`maxCachedReviewBytes`、`reviewRefreshMs`、`maxCommits`、`testPaths` 与 `interfacePaths` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 “全部文件”打开可搜索的目录树,并把选中文件滚动到中间;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,引用显示文件名、侧与行号以及评论,悬停时显示文件路径;多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 @@ -69,7 +70,7 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 -Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。`changeRequestReview` 同样只作用于可见的绑定;head 与最新状态的 head 不同时,读取前就以 `forgePanel/stale-head` 拒绝;随后调用提供方的 `readReview`,把 `stale` 映射为 `forgePanel/stale-head`、`unsupported` 映射为 `forgePanel/review-unsupported`、其余失败映射为 `forgePanel/review-failed`。每个变更请求保留一次读取结果,以 head 提交和更新时间为键;保留的结果按 JSON 计超过 `maxCachedReviewBytes` 时,最久未读的先被丢弃;保留时间达到 `reviewRefreshMs` 后重新读取,因为解决 GitHub 讨论不会改变更新时间。 `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 @@ -95,6 +96,11 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | 分层透镜、测试文件开关与已审阅进度 | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | 提交选择器 | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | 保存在浏览器中的已审阅标记 | +| [`src/client/ReviewThreads.tsx`](src/client/ReviewThreads.tsx) | 评审讨论卡片与已过期区域 | +| [`src/client/ThreadList.tsx`](src/client/ThreadList.tsx) | 讨论列表 | +| [`src/client/review-threads.ts`](src/client/review-threads.ts) | 讨论位置、列表筛选、hunk 摘录与讨论引用 | +| [`src/client/thread-state.ts`](src/client/thread-state.ts) | 读取讨论并确定它们在所显示变更中的位置 | +| [`src/review-cache.ts`](src/review-cache.ts) | 保存在内存中的评审讨论 | | [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | 变更文件的标签:文件名在前,目录在后 | | [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | 新会话的工作树选项 | | [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | worktree 创建卡片与 worktree 缺失卡片 | @@ -117,7 +123,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi ## 模型体验 -间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。“添加到输入框”提供以 `## User Comment N` 开头的冻结引用,包含 File、Side、Lines、Source、diff hunk 与 Comment;PR 引用还包含评审的提交,本地“分支”引用还包含视图比较的基准,来自所选提交的引用还会注明这些提交。只有发送主输入框后,这些引用才进入正常记录的用户消息。检查、状态、恢复、会话列表与 GitHub 评审提交不会增加模型上下文。 +间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。“添加到输入框”提供以 `## User Comment N` 开头的冻结引用,包含 File、Side、Lines、Source、diff hunk 与 Comment;PR 引用还包含评审的提交,本地“分支”引用还包含视图比较的基准,来自所选提交的引用还会注明这些提交。只有发送主输入框后,这些引用才进入正常记录的用户消息。检查、状态、恢复、会话列表与 GitHub 评审提交不会增加模型上下文。对评审讨论使用“添加到输入框”时,会加入一段固定的 `## Review Thread N` 引用,包含 File、Side、Lines、这些行号所在的提交、State、Source、记录下的 diff hunk,以及逐行引用的每条评论的作者、时间和正文。 #### KV Cache 影响 @@ -140,6 +146,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。 - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 - **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。 +- **评审讨论的操作**——讨论是只读的:回复、解决、编辑评论,以及 GitHub 待提交评审中的草稿,留待提供方的评审写入能力实现。被折叠隐藏的评论与其他评论显示方式相同;头像由浏览器从代码托管平台的图片服务器加载。 - **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。 - **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为最早的 250 个),“分支”范围则列出最新的 `maxCommits` 个;范围必须沿第一父提交相连,因此已合并分支的提交只能逐个查看,不能与本分支的提交一起查看;每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。 - **代码审查页**——待办:在左侧导航中新增一个页面,列出用户在所选提供方上的变更请求;[决策笔记](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md)记录了它的接入位置。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 5763304362..d550841145 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -947,7 +947,7 @@ export function ChangesTab({
} - {review !== null && ( + {review !== null && review.threads.length > 0 && (
} const original = place.kind === 'outdated' && thread.original !== null ? thread.original : null + const label = original !== null ? t('thread.originally', { range: anchorRange(thread.side, original) }) : range ?? t('thread.fileLevel') return
+ data-resolved={thread.resolved ? '' : undefined} aria-label={label}>
- {original !== null ? t('thread.originally', { range: anchorRange(thread.side, original) }) - : range ?? t('thread.fileLevel')} + {label} {commit !== null && <> · {t('thread.onCommit', { commit })}} {place.kind === 'outdated' && {t('thread.outdatedTag')}} diff --git a/packages/experimental/client-ui-forge/src/client/review-threads.ts b/packages/experimental/client-ui-forge/src/client/review-threads.ts index 831b2acc0a..5080059646 100644 --- a/packages/experimental/client-ui-forge/src/client/review-threads.ts +++ b/packages/experimental/client-ui-forge/src/client/review-threads.ts @@ -164,8 +164,10 @@ export function formatThreadQuote(thread: ForgeReviewThread, url: string, index: const state = [thread.resolved ? 'resolved' : 'unresolved', ...thread.subject === 'line' && thread.current === null ? ['outdated'] : []].join(', ') const fence = thread.diffHunk === null ? '' : '`'.repeat(Math.max(3, ...Array.from(thread.diffHunk.matchAll(/`+/g), match => match[0].length + 1))) const hunk = thread.diffHunk === null ? '' : `Diff hunk:\n${fence}diff\n${thread.diffHunk}\n${fence}\n` + // A blank comment line becomes a bare `>` and comments follow one another without blank lines, since the composer + // quotes every line of the reference again. const comments = thread.comments.map(comment => `${comment.author === null ? 'Unknown author' : `@${comment.author.login}`} (${comment.createdAt}):\n` - + comment.body.split('\n').map(line => `> ${line}`).join('\n')).join('\n\n') + + comment.body.split('\n').map(line => line.trim() === '' ? '>' : `> ${line}`).join('\n')).join('\n') const link = thread.comments[0]?.url ?? url return `## Review Thread ${index}\nFile: ${thread.path}\n${where}\nState: ${state}\nSource: ${link}\n${hunk}Comments:\n${comments}` } diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index 9cbb1f9656..88485d2ea6 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -1786,6 +1786,13 @@ describe('ChangesTab review threads', () => { expect(card(m, 'on-row')).toBeNull() }) + it('offers no thread list for a change request without threads', async () => { + const m = mount({ source, setup: reviewed({ ...REVIEW, threads: [] }) }) + await settle() + expect(m.faces.changeRequestReview).toHaveBeenCalledOnce() + expect(m.view.queryByRole('button', { name: /^Review threads/ })).toBeNull() + }) + it('names no author when the host hides the change request\'s author', async () => { const m = mount({ source, state: ready({ changeRequests: [entry(7, snapshot(7, { author: null }))] }), setup: reviewed({ ...REVIEW, threads: [line('on-row', 'src/app.ts', 'R', 4, { comments: [reviewComment({ author: { login: 'octo', avatarUrl: null, bot: false } })] })] }) }) diff --git a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx index b6173e7853..b29d22434b 100644 --- a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx @@ -108,7 +108,7 @@ describe('ThreadCard', () => { let answer: (value: RemoteResult) => void = () => {} const shared = actions({ readText: vi.fn(() => new Promise>((resolve) => { answer = resolve })) }) const view = render() - const card = view.getByRole('region', { name: en['thread.fileLevel'] }) + const card = view.getByRole('region', { name: 'Originally R2–3' }) expect(card.textContent).toContain(`Originally R2–3 · on ${OLD.slice(0, 7)}`) expect([...card.querySelectorAll('[class*="excerptLine"]')].map(line => line.textContent)).toEqual(['@@ -1 +1,3 @@', ' a', '+b', '+c']) fireEvent.click(view.getByRole('button', { name: en['thread.viewOriginal'] })) diff --git a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts index 2942e99f8a..1d793a7fc3 100644 --- a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts @@ -109,13 +109,13 @@ describe('thread quotes', () => { it('formats a line thread with its hunk, state, and every comment quoted line by line', () => { const thread: ForgeReviewThread = reviewThread({ current: { commit: HEAD, startLine: 3, endLine: 5 }, diffHunk: '@@ -1 +1 @@\n-```\n+````', - comments: [reviewComment({ body: '# Heading\nsecond line', url: 'https://github.com/octo/demo/pull/7#discussion_r1' }), + comments: [reviewComment({ body: '# Heading\n\nsecond line', url: 'https://github.com/octo/demo/pull/7#discussion_r1' }), reviewComment({ author: null, body: 'reply' })], }) expect(formatThreadQuote(thread, 'https://github.com/octo/demo/pull/7', 2)).toBe([ '## Review Thread 2', 'File: src/a.ts', 'Side: R', 'Lines: 3-5', `Commit: ${HEAD}`, 'State: unresolved', 'Source: https://github.com/octo/demo/pull/7#discussion_r1', 'Diff hunk:', '`````diff', '@@ -1 +1 @@\n-```\n+````', '`````', - 'Comments:', '@reviewer (2026-11-05T08:00:00Z):', '> # Heading', '> second line', '', 'Unknown author (2026-11-05T08:00:00Z):', '> reply', + 'Comments:', '@reviewer (2026-11-05T08:00:00Z):', '> # Heading', '>', '> second line', 'Unknown author (2026-11-05T08:00:00Z):', '> reply', ].join('\n')) }) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index c329bf236f..6d3ca2d441 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: 0f8041e0380036c9 - zh: 63cbdc82c9f438d4 + en: dc1fc7eb436482c8 + zh: 968aa86136fdd840 /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: - en: f98e77171cba809f - zh: 1b330cb342d69efe + en: f3d0a577b0a4db0a + zh: 0c15ac7979909117 /deepseek-ai-dsh-experimental-forge-github/further-exploration: en: d377d5fd9d9f190e zh: 920bcbfdb8c993f2 @@ -30,8 +30,8 @@ en: 26b946af4b742e17 zh: 00b34f0721ac72f6 /deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work: - en: 9f13ae6d928e4ef7 - zh: 09a549b1655f4de3 + en: b5ad30bb209cdd48 + zh: eaea2d4cbe350ce4 /deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 84a261269a..0d5f8ea6b6 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -50,6 +50,8 @@ A pull request is `mergeReady` when it is open, not a draft, GitHub reports its Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. +`readReview` reads review threads with one GraphQL query that names `headRefOid` and the signed-in viewer, and returns `stale` when the head is not the requested commit. It reads threads in pages of up to 100 until it has read `maxReviewThreads` of them, each page after the first naming the head again, so a push during the read returns `stale`; it reads every comment of each thread, and the commits and `diffHunk` of a thread's first comment once. All pages together, measured as JSON, must fit `maxOutputBytes`: the page that would pass it is dropped with the threads after it, and the result is marked `truncated`, as when more than `maxReviewThreads` threads exist. A thread on lines gets a `current` position only when GitHub reports it not outdated, with a `line`, and its first comment's `commit` is the head: a comment written on an earlier commit after a force-push keeps a `line` that numbers that commit, and GitHub can mark a thread outdated while keeping its `line`. `original` comes from `originalLine` and `originalStartLine` at the first comment's `originalCommit`; `subjectType: FILE` threads have neither. A range whose start is on the other side of the diff keeps its last line. Threads without comments are left out. REST's `position` does not tell outdated threads apart and is not read. + ----- @@ -66,6 +68,7 @@ A fetch groups references by host and sends one initial `gh api graphql` query p | [`src/provider.ts`](src/provider.ts) | URL mapping, host grouping, and failure classification | | [`src/graphql.ts`](src/graphql.ts) | Query construction and response validation and mapping | | [`src/compare.ts`](src/compare.ts) | Patch between two commits through the compare API, shared by every diff read | +| [`src/threads.ts`](src/threads.ts) | Review thread read, pagination, and mapping onto head and original positions | | [`src/gh.ts`](src/gh.ts) | Host `gh` execution with environment scrubbing and limits | | [`src/urls.ts`](src/urls.ts) | Remote URL and pull request URL parsing, and validation of inlined names and commits | @@ -96,11 +99,11 @@ None. Provider output reaches only host-side watchers. - **GitHub CLI on the host** — `gh` must be installed and signed in for each configured host; tokens in environment variables are not forwarded. Hosts missing from `hosts` are never read, including references retained across configuration changes. A status fetch returns `unavailable` for those references; a head lookup rejects before starting `gh`. -- **Review thread bound** — a status read counts at most `maxReviewThreads` review threads per pull request; beyond it `unresolvedThreads` is a lower bound that `threadsTruncated` marks. +- **Review thread bound** — a status read counts, and a review read reads, at most `maxReviewThreads` review threads per pull request; beyond it `unresolvedThreads` is a lower bound that `threadsTruncated` marks, and a review read is `truncated`. - **Batch deadline** — continuation pages of a pull request with many review threads lengthen its host's polling batch; a batch that outlives `ctx.forge`'s `fetchTimeoutMs` reports `unavailable` for every pull request in it, not only for the one with many threads. - **Rewritten base branch** — the diff starts from the merge base of the head and the base commit GitHub recorded at the pull request's last update. After the base branch is force-pushed, that merge base can differ from the one GitHub's pull request view uses, so the patch can include or omit commits until the pull request is updated. - **No fork discovery** — head lookups exclude pull requests whose head is in another repository. -- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request. All count against the signed-in account’s rate limit. +- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request. All count against the signed-in account’s rate limit. A review read costs one GraphQL query per page of 100 threads and one more per further page of 100 comments in a thread. - **No merge queue** — a base branch that requires a merge queue refuses a direct merge, and the provider never queues one. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 205a8f0439..99896c73d8 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -50,6 +50,8 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 +`readReview` 用一次 GraphQL 查询读取评审讨论,同时读出 `headRefOid` 和当前登录的用户;head 不是所请求的提交时返回 `stale`。讨论按每页最多 100 个分页读取,直到读满 `maxReviewThreads` 个;第一页之后的每一页都会再次读取 head,因此读取期间有新的推送时返回 `stale`。每个讨论的评论都会全部读出,讨论第一条评论的提交和 `diffHunk` 只读一次。所有页面按 JSON 计合计不得超过 `maxOutputBytes`:会超出上限的那一页及其后的讨论被丢弃,结果标记为 `truncated`,与讨论数超过 `maxReviewThreads` 时相同。行级讨论只有在 GitHub 报告它未过期、带有 `line`,且第一条评论的 `commit` 就是 head 时,才得到 `current` 位置:force-push 之后写在旧提交上的评论保留的 `line` 是那个旧提交的行号,GitHub 也可能在标记讨论过期的同时保留它的 `line`。`original` 取自第一条评论 `originalCommit` 上的 `originalLine` 和 `originalStartLine`;`subjectType: FILE` 的讨论两者都没有。起始行在 diff 另一侧的范围只保留最后一行。没有评论的讨论会被略去。REST 的 `position` 无法区分已过期的讨论,因此不读取。 + ----- @@ -66,6 +68,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 | [`src/provider.ts`](src/provider.ts) | URL 映射、按主机分组与失败分类 | | [`src/graphql.ts`](src/graphql.ts) | 构造查询,并校验和映射响应 | | [`src/compare.ts`](src/compare.ts) | 经 compare API 读取两个提交之间的补丁,供所有 diff 读取共用 | +| [`src/threads.ts`](src/threads.ts) | 读取评审讨论、分页,并映射到 head 位置与原始位置 | | [`src/gh.ts`](src/gh.ts) | 在宿主上执行 `gh`,清理环境变量并施加限制 | | [`src/urls.ts`](src/urls.ts) | 解析远端 URL 与 pull request URL,并校验写入请求的名称与提交 | @@ -96,11 +99,11 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 - **宿主需要 GitHub CLI**——必须安装 `gh` 并为每个已配置主机登录;环境变量中的令牌不会被转发。不在 `hosts` 中的主机永远不会被读取,包括配置变更后保留的引用。状态读取为这些引用返回 `unavailable`;head 查找会在启动 `gh` 前拒绝。 -- **评审讨论上限**——每次状态读取每个 pull request 最多统计 `maxReviewThreads` 个评审讨论;超出时 `unresolvedThreads` 只是下限,由 `threadsTruncated` 标出。 +- **评审讨论上限**——每次状态读取每个 pull request 最多统计、每次评审读取最多读取 `maxReviewThreads` 个评审讨论;超出时 `unresolvedThreads` 只是下限,由 `threadsTruncated` 标出,评审读取的结果标记为 `truncated`。 - **批次时限**——评审讨论很多的 pull request 的续页会拉长其主机的轮询批次;批次超过 `ctx.forge` 的 `fetchTimeoutMs` 时,批次内每个 pull request 都报告 `unavailable`,而不只是讨论很多的那个。 - **基准分支被改写**——diff 从 head 与 GitHub 在 pull request 最后一次更新时记录的基准提交的合并基准开始。基准分支被强制推送后,该合并基准可能不同于 GitHub pull request 页面使用的合并基准,因此在 pull request 更新前,补丁可能多出或缺少提交。 - **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。 -- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求。这些都计入已登录账号的速率配额。 +- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求。这些都计入已登录账号的速率配额。每次评审读取每页 100 个讨论消耗一次 GraphQL 查询,单个讨论的评论每多一页(100 条)再消耗一次。 - **不支持合并队列**——要求合并队列的基准分支会拒绝直接合并,提供方也不会把变更请求加入队列。 diff --git a/packages/experimental/forge-github/src/index.ts b/packages/experimental/forge-github/src/index.ts index 7daff01956..3235e030a3 100644 --- a/packages/experimental/forge-github/src/index.ts +++ b/packages/experimental/forge-github/src/index.ts @@ -18,9 +18,12 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read or review read. */ maxOutputBytes?: number - /** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */ + /** + * Most review threads counted per pull request in one status read, which reports a lower bound past it, and read in + * one review read, which reports itself truncated past it. + */ maxReviewThreads?: number /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ maxCommits?: number diff --git a/packages/experimental/forge-github/tests/threads.spec.ts b/packages/experimental/forge-github/tests/threads.spec.ts index f90c18b0a0..7962e90280 100644 --- a/packages/experimental/forge-github/tests/threads.spec.ts +++ b/packages/experimental/forge-github/tests/threads.spec.ts @@ -56,15 +56,20 @@ function nextPage(threads: Record, headRefOid = head): Record | { readonly raw: GhOutput }): answer is { readonly raw: GhOutput } { + return 'raw' in answer +} + /** Answer the first thread page with `first`, later thread pages from `later` by cursor, and comment pages from `comments` by cursor. */ function review( - first: Record | GhOutput, + first: Record | { readonly raw: GhOutput }, later: Readonly>> = {}, comments: Readonly>> = {}, ): ReturnType { return fakeGh((args) => { const cursor = args.find(arg => arg.startsWith('cursor='))?.slice('cursor='.length) ?? '' - if (args.includes(`query=${REVIEW_QUERY}`)) return 'exitCode' in first ? first : output(first) + if (args.includes(`query=${REVIEW_QUERY}`)) return isRaw(first) ? first.raw : output(first) if (args.includes(`query=${REVIEW_THREADS_QUERY}`)) return output(later[cursor] ?? { data: { node: null } }) if (args.includes(`query=${THREAD_COMMENTS_QUERY}`)) return output(comments[cursor] ?? { data: { node: null } }) throw new Error(`unexpected gh ${args.join(' ')}`) @@ -221,14 +226,14 @@ describe('GitHub provider review threads', () => { }) it('passes on the failure of any page read and of a response that is not JSON', async () => { - const missing = review(output('', 1, 'gh: Could not resolve to a PullRequest with the number of 7.')) + const missing = review({ raw: output('', 1, 'gh: Could not resolve to a PullRequest with the number of 7.') }) await expect(provider(missing.run).readReview(pull(7), head, signal)) .resolves.toEqual({ code: 'not-found', message: 'gh: Could not resolve to a PullRequest with the number of 7.' }) - const garbled = review(output('not json')) + const garbled = review({ raw: output('not json') }) await expect(provider(garbled.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message: 'unexpected GitHub response' }) - const refused = review(output({ data: null, errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] })) + const refused = review({ raw: output({ data: null, errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] }) }) await expect(provider(refused.run).readReview(pull(7), head, signal)) .resolves.toEqual({ code: 'unavailable', message: 'Resource not accessible by integration' }) diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml index ce02168f4c..2b96a443f1 100644 --- a/packages/experimental/forge/README.i18n.yaml +++ b/packages/experimental/forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge/use-this-package: - en: 66ce801db0de565f - zh: 884973fad2ad2540 + en: 2327b151ac15aa2f + zh: 429988457f4a67bd /deepseek-ai-dsh-experimental-forge/understand-the-implementation: en: e193cb547c47069b zh: faa517522402ea41 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md index 8727e8cd2b..72bdb80f85 100644 --- a/packages/experimental/forge/README.md +++ b/packages/experimental/forge/README.md @@ -48,6 +48,8 @@ Reaching `fetchTimeoutMs` aborts the fetch signal with `FORGE_FETCH_TIMEOUT`. Pr Providers may implement `submitReview` to publish a line comment or a completed review at an explicit head commit. The registry returns unsupported when that capability is absent and refreshes watched status after a submission. Successful writes return their web URL; failures distinguish changed heads, refusals, and unknown outcomes. Callers must reconcile unknown outcomes before another submission. Pending drafts belong to the consumer, not the polling registry. +Providers may implement `readReview` to read a change request's review threads at one head commit. `readReview(ref, headSha, signal)` returns `ForgeReviewData`, the threads with each line thread's position at the head and at the commit it was written on, only when the read names `headSha` as the head; otherwise `stale`, which polls a watched change request at once, once per head commit shared with stale diff reads. The registry returns an `unsupported` failure when the provider lacks the method. + ----- diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md index 168012126c..543f6ca83f 100644 --- a/packages/experimental/forge/README.zh.md +++ b/packages/experimental/forge/README.zh.md @@ -48,6 +48,8 @@ kind: "package-reference" provider 可以实现 `submitReview`,在指定的 head 提交上发布行内评论或完整评审。未实现该能力时,注册表返回不支持;提交结束后会刷新受关注的状态。成功的写操作返回网页 URL;失败区分 head 变化、明确拒绝与结果不明。调用方必须先核对结果不明的操作,再次提交。待提交草稿由消费者管理,不由轮询注册表保存。 +提供方可以实现 `readReview`,在一个 head 提交处读取变更请求的评审讨论。只有读取时的 head 就是 `headSha` 时,`readReview(ref, headSha, signal)` 才返回 `ForgeReviewData`,其中每个行级讨论都带有它在 head 上的位置和在写下时所在提交上的位置;否则返回 `stale`,并立即重新轮询被关注的变更请求,每个 head 提交只轮询一次,这个次数与 diff 读取遇到 `stale` 时共用。提供方未实现该方法时,注册表返回 `unsupported` 失败。 + ----- diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index de37043f87..1c342a0672 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1276,6 +1276,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it at `headSha`.', throws: ['when no provider serves the change request\'s forge, or with `signal.reason` when it aborts.'], }, + { + signature: 'async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise', + description: 'Read the review threads of a change request at one head commit. A `stale` failure polls a watched change request again as ForgeService.diff does.', + parameters: [{ name: 'ref', description: 'change request served by a registered provider.' }, { name: 'headSha', description: 'head commit the threads must be read at, usually the head of the displayed diff.' }, { name: 'signal', description: 'cancellation of the read.' }], + returns: 'the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews.', + throws: ['when no provider serves the change request\'s forge, or with `signal.reason` when it aborts.'], + }, { signature: 'async commits(ref: ChangeRequestRef, signal: AbortSignal): Promise', description: 'List the commits of a change request at its current head.', @@ -1433,6 +1440,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the text.', throws: ['`forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; `forgePanel/diff-too-large` when the host or its provider refuses the file over a size limit; `forgePanel/diff-failed` when the host does not provide the file otherwise.'], }, + { + signature: '@Remote async changeRequestReview(session: Session, url: string, headSha: string, signal: AbortSignal): Promise', + description: 'Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read at the same head commit and update time are reused from memory, for any Session, until they are `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved since, which do not change a GitHub pull request\'s update time.', + parameters: [{ name: 'session', description: 'Session the change request is bound to.' }, { name: 'url', description: 'binding URL of the change request.' }, { name: 'headSha', description: 'head commit of the displayed diff.' }, { name: 'signal', description: 'request cancellation.' }], + returns: 'the threads at `headSha`, with `current` positions numbering the files of that diff.', + throws: ['`forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the status was read and when the host does not provide the threads otherwise.'], + }, { signature: '@Remote async submitReview(session: Session, url: string, request: ForgeReviewRequest, signal: AbortSignal): Promise', description: 'Publish a line comment or completed review for a visible Session binding.', @@ -6621,6 +6635,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ForgeActionFailure', declaration: 'export interface ForgeActionFailure {\n readonly code: \'unauthenticated\' | \'stale\' | \'failed\';\n readonly message: string;\n}', }, + { + name: 'ForgeAnchor', + declaration: 'export interface ForgeAnchor {\n readonly commit: string;\n readonly startLine: number;\n readonly endLine: number;\n}', + }, { name: 'ForgeAutomaticBase', declaration: 'export interface ForgeAutomaticBase {\n readonly name: string;\n readonly worktree: boolean;\n}', @@ -6661,6 +6679,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ForgeCheckoutState', declaration: 'export type ForgeCheckoutState = {\n readonly state: \'pending\';\n} | {\n readonly state: \'none\';\n} | {\n readonly state: \'failed\';\n readonly message: string;\n} | {\n readonly state: \'ready\';\n readonly checkout: ForgeCheckout;\n};', }, + { + name: 'ForgeCommentAuthor', + declaration: 'export interface ForgeCommentAuthor {\n readonly login: string;\n readonly avatarUrl: string | null;\n readonly bot: boolean;\n}', + }, + { + name: 'ForgeCommentId', + declaration: 'export type ForgeCommentId = Branded<\'ForgeCommentId\'>;', + }, { name: 'ForgeCommit', declaration: 'export interface ForgeCommit {\n readonly sha: string;\n readonly parent: string | null;\n readonly title: string;\n readonly author: string | null;\n readonly authoredAt: string;\n}', @@ -6687,7 +6713,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ForgeFailure', - declaration: 'export interface ForgeFailure {\n readonly code: \'not-found\' | \'unauthenticated\' | \'too-large\' | \'stale\' | \'unavailable\';\n readonly message: string;\n}', + declaration: 'export interface ForgeFailure {\n readonly code: \'not-found\' | \'unauthenticated\' | \'too-large\' | \'stale\' | \'unsupported\' | \'unavailable\';\n readonly message: string;\n}', }, { name: 'ForgeFileLayer', @@ -6731,7 +6757,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ForgeProvider', - declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise;\n readiness(host: string, signal: AbortSignal): Promise;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n compare?(ref: ChangeRequestRef, base: string, head: string, s /* …truncated — full shape in source */', + declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise;\n readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise;\n readiness(host: string, signal: AbortSignal): Promise;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: A /* …truncated — full shape in source */', }, { name: 'ForgeReadiness', @@ -6741,6 +6767,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ForgeRepository', declaration: 'export interface ForgeRepository {\n readonly forge: string;\n readonly host: string;\n readonly owner: string;\n readonly name: string;\n}', }, + { + name: 'ForgeReviewComment', + declaration: 'export interface ForgeReviewComment {\n readonly id: ForgeCommentId;\n readonly author: ForgeCommentAuthor | null;\n readonly body: string;\n readonly createdAt: string;\n readonly pending: boolean;\n readonly url: string | null;\n}', + }, + { + name: 'ForgeReviewData', + declaration: 'export interface ForgeReviewData {\n readonly headSha: string;\n readonly viewer: string | null;\n readonly threads: readonly ForgeReviewThread[];\n readonly truncated: boolean;\n}', + }, { name: 'ForgeReviewRequest', declaration: 'export type ForgeReviewRequest = {\n readonly headSha: string;\n} & ({\n readonly kind: \'comment\';\n readonly comment: ForgeLineComment;\n} | {\n readonly kind: \'review\';\n readonly event: \'comment\' | \'approve\' | \'request-changes\';\n readonly body: string;\n readonly comments: readonly ForgeLineComment[];\n});', @@ -6749,10 +6783,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ForgeReviewResult', declaration: 'export type ForgeReviewResult = {\n readonly ok: true;\n readonly url: string;\n} | {\n readonly ok: false;\n readonly code: \'unsupported\' | \'stale\' | \'failed\' | \'unknown\';\n readonly message: string;\n};', }, + { + name: 'ForgeReviewThread', + declaration: 'export interface ForgeReviewThread {\n readonly id: ForgeThreadId;\n readonly path: string;\n readonly side: \'L\' | \'R\';\n readonly subject: \'line\' | \'file\';\n readonly current: ForgeAnchor | null;\n readonly original: ForgeAnchor | null;\n readonly diffHunk: string | null;\n readonly resolved: boolean;\n readonly comments: readonly ForgeReviewComment[];\n}', + }, { name: 'ForgeSessionWorktree', declaration: 'export interface ForgeSessionWorktree {\n readonly path: string;\n readonly branch: string;\n readonly from: string | null;\n readonly baseCommit: string;\n readonly repositoryRoot: string;\n}', }, + { + name: 'ForgeThreadId', + declaration: 'export type ForgeThreadId = Branded<\'ForgeThreadId\'>;', + }, { name: 'ForgeWorktreeBlocker', declaration: 'export type ForgeWorktreeBlocker = \'not-git\' | \'outside-writable-root\' | \'read-only\';', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 92c46bf4b5..ee6fdd75bf 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -428,6 +428,7 @@ export const LINK_MAP: Readonly> = { ForgeChangeRequestDiff: 'forge.md', ForgeCommit: 'forge.md', ForgeCommitList: 'forge.md', + ForgeReviewData: 'forge.md', ForgeCommitRange: 'forge.md', ForgeFileLayer: 'forge.md', ForgeChangeRequestStatesFrame: 'forge.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index eb54361c8d..ad71d6ad42 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2821,6 +2821,41 @@ "doc": "docs/subsystems/forge.md", "symbol": "ForgeReviewResult", "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeThreadId", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeCommentId", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeAnchor", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeCommentAuthor", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeReviewComment", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeReviewThread", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeReviewData", + "source": "packages/experimental/forge/src/types.ts" } ] } From 409b0b1ab9be8466f954bec85f1265e786175fb1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 14:33:30 +0800 Subject: [PATCH 38/81] fix(cua): keep physical hover specific to supporting providers --- .../2026-10-13-cua-continuous-hover.i18n.yaml | 6 ++--- .../2026-10-13-cua-continuous-hover.md | 4 ++-- .../2026-10-13-cua-continuous-hover.zh.md | 2 +- ...cua-hover-routes-and-coordinates.i18n.yaml | 4 ++-- ...-10-13-cua-hover-routes-and-coordinates.md | 2 +- ...-13-cua-hover-routes-and-coordinates.zh.md | 2 +- .../tests/provider-lifecycle.spec.ts | 23 ++++++++++++++++++- packages/experimental/cua/src/index.ts | 6 ++--- 8 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml index 04a78f70d3..1dbc38b5ae 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml @@ -9,10 +9,10 @@ en: cdc04139c8d69415 zh: a3fe3e78ada08d95 /agent-note-continuous-cua-hover-without-cross-call-ownership/decision: - en: 125c860c1c568960 - zh: 8a2ece1101358200 + en: 193acf8c23672457 + zh: e614884d52585b16 /agent-note-continuous-cua-hover-without-cross-call-ownership/alternatives-considered: - en: 18d917ee2e909b93 + en: df67e547bce2f289 zh: 5b5ad0530f138cda /agent-note-continuous-cua-hover-without-cross-call-ownership/consequences: en: 714789d0a472a308 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md index 2cce428cff..29adedf224 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md @@ -10,7 +10,7 @@ Hover interactions span movement, delayed content, observation and input inside ## Decision -An explicit Window exposes `mouseMove` for coordinates or an observed element ID. The result reports `hoverRoute` and `systemPointerMoved`. It never presses a mouse button; disabled controls can use system-pointer movement with `systemPointerHover` enabled. Completion, AX reads and screenshots do not restore a previous pointer position. Successive calls can visit intermediate points before entering a popup. Clicks deliver button events at their target without an off-window priming click. The [input-routing decision](2026-10-13-cua-hover-routes-and-coordinates.md) selects AX alternate UI before allowing opt-in system movement with foreground and mouse-hit checks. It never activates or raises the app implicitly. Other input or window changes can race that check. +An explicit Window exposes `mouseMove` for coordinates or an observed element ID. The result reports `hoverRoute` and `systemPointerMoved`. It never presses a mouse button. With session approval, system-pointer movement also works on disabled controls. Completion, AX reads and screenshots do not restore a previous pointer position. Successive calls can visit intermediate points before entering a popup. Clicks deliver button events at their target without an off-window priming click. The [input-routing decision](2026-10-13-cua-hover-routes-and-coordinates.md) requires foreground and mouse-hit checks for system movement. It never activates or raises the app implicitly. Other input or window changes can race that check. The [shared helper](2026-10-09-cua-shared-native-helper.md) serializes complete individual operations. It grants no application ownership across calls. Earlier arrival, a retained app object and Session lifetime do not establish task priority. Waiting for the model does not reserve the application. Explicit task coordination determines any required execution order. @@ -20,7 +20,7 @@ The [shared helper](2026-10-09-cua-shared-native-helper.md) serializes complete **Click or drag as hover** introduces button input that can activate a control, select text or dismiss a popup. Hover requires a separate unpressed movement operation. -**PID-directed virtual movement** can display Chromium hover content after prior interaction, but a fresh window can ignore it, and later pointer checks can dismiss the content. Native AppKit tracking also requires real pointer movement in the measured fixture. The system-pointer fallback avoids reporting this unreliable event route as continuous hover. +**PID-directed virtual movement** can display Chromium hover content after prior interaction, but a fresh window can ignore it, and later pointer checks can dismiss the content. Native AppKit tracking also requires real pointer movement in the measured fixture. The approved system-pointer route avoids reporting this unreliable event route as continuous hover. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md index cc2457a70a..22668bd215 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -显式窗口提供 `mouseMove`,接受窗口坐标或已观察元素的字符串 ID,结果用 `hoverRoute` 和 `systemPointerMoved` 报告执行路径。悬停不按下鼠标按钮,启用 `systemPointerHover` 后可通过系统鼠标悬停禁用控件。调用结束、AX 读取和截图不恢复先前的鼠标位置。进入浮层前可连续调用并经过中间位置。点击只在目标位置发送按键事件,不先在窗口外点击。 [输入路径决定](2026-10-13-cua-hover-routes-and-coordinates.zh.md)优先选择 AX 替代界面,只有启用该配置时才回退到经过前台和鼠标命中检查的系统移动,不隐式激活或抬起应用。其他输入或窗口变化仍可能与检查交错。 +显式窗口提供 `mouseMove`,接受窗口坐标或已观察元素的字符串 ID,结果用 `hoverRoute` 和 `systemPointerMoved` 报告执行路径。悬停不按下鼠标按钮,用户授权当前会话移动鼠标后,也可悬停在禁用的控件上。调用结束、AX 读取和截图不恢复先前的鼠标位置。进入浮层前可连续调用并经过中间位置。点击只在目标位置发送按键事件,不先在窗口外点击。 [输入路径决定](2026-10-13-cua-hover-routes-and-coordinates.zh.md)要求移动系统鼠标前检查前台应用和鼠标命中的窗口,不隐式激活或抬起应用。其他输入或窗口变化仍可能与检查交错。 [共享 helper](2026-10-09-cua-shared-native-helper.zh.md)串行执行完整的单次操作,不分配跨调用的应用所有权。先发起请求、持有 app 对象和 Session 寿命均不代表任务优先级。等待模型期间不占用应用。需要确定执行顺序时,由明确的任务协调决定。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml index 9775baf3a4..f6546d2ce8 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml @@ -9,8 +9,8 @@ en: f96bf1ebc0719fb0 zh: e9a1144f0e76572d /agent-note-background-hover-and-screenshot-coordinates/decision: - en: bdf4d3386521ce00 - zh: 96db93177ad96a76 + en: 9e06d78eba10770d + zh: 25e985aa49f75dd8 /agent-note-background-hover-and-screenshot-coordinates/alternatives-considered: en: 5fc4beaf79761c50 zh: 4948333b0020be28 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md index c385525731..a1f0a7fac1 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md @@ -10,7 +10,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer. ## Decision -Native hover first uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. Enabling `systemPointerHover` permits the real system pointer fallback. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). +By default, native hover uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. After the user approves `cua.requestMouseControl()`, each `mouseMove` in that session uses the system pointer until DSH restarts. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for `click`, `mouseMove`, `scroll` and `drag`; it does not apply to opaque element IDs. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md index 0d3db32400..f079ff736f 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -原生悬停优先寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。启用 `systemPointerHover` 后才允许移动系统光标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 +默认情况下,原生悬停会寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。用户同意 `cua.requestMouseControl()` 后,本会话的每次 `mouseMove` 都使用系统光标,重启 DSH 后授权失效。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 面向模型的坐标默认使用返回截图中的像素。`click`、`mouseMove`、`scroll` 和 `drag` 传入 `relative: true` 时接受 0~999 的千分比整数,仅用于坐标目标。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index f975862baa..922078987a 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -17,7 +17,7 @@ import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import Hmr from '@deepseek-ai/dsh-hmr' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use' -import { CuaConfig } from '@deepseek-ai/dsh-cua' +import { CuaConfig, installCua } from '@deepseek-ai/dsh-cua' import { createConfigProjector } from '@deepseek-ai/dsh-app-boot/src/config-schema/projector.ts' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import FileSystem from '@deepseek-ai/dsh-fs-local' @@ -927,8 +927,10 @@ it('drops late approval after cancellation and admits only one pending question' expect((await f.execute('requestMouseControl')).isError).toBe(true) await f.ctx.plugin(UserQuestions) const entered = Promise.withResolvers() + let count = 0 const release = Promise.withResolvers() f.ctx.on('user-questions/request', async () => { + count++ entered.resolve(undefined) await release.promise return { answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }] } @@ -941,5 +943,24 @@ it('drops late approval after cancellation and admits only one pending question' release.resolve(undefined) await Promise.allSettled([first]) expect((await f.execute('requestMouseControl')).value).toBe(true) + expect(count).toBe(2) expect(native.request).not.toHaveBeenCalled() }) + +it('keeps provider-specific pointer flags and questions off providers without mouse control', async () => { + const f = await setup() + await f.provider.dispose() + await f.ctx.plugin({ name: 'fixture-window-provider', inject: NativeProvider.inject, apply(ctx: Context) { + installCua(ctx, CuaConfig(config), { + helperPath: config.helperPath, createChannel() { throw new Error('Unexpected native channel') }, + guidance: '', permissionParameters: () => ({}), + capabilities: { platform: 'fixture', methods: ['getApp', 'mouseMove'], appLevelAccessibility: true, + keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, + }) + } }) + expect((await f.execute('requestMouseControl')).isError).toBe(true) + await selectApplication(f) + const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } + expect((await f.execute('mouseMove', target)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', target, expect.any(AbortSignal), expect.any(String)) +}) diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index 40cb0bf1cd..763e7bc0ec 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -157,7 +157,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) permissionParameters: backend.permissionParameters, store: new CuaPermissionStore(ctx, config.allowedApps.map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), config.timeoutMs), }) - const mouseControl = new MouseControl(ctx) + const mouseControl = backend.capabilities.methods.includes('requestMouseControl') ? new MouseControl(ctx) : undefined const clients = new Map() const lifetime = new AbortController() const pending = new Map, Agent>() @@ -222,7 +222,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) ? { ...params, maxElements: config.maxElements, maxDepth: config.maxDepth } : method === 'drag' ? { ...params, durationMs: config.dragDurationMs, steps: config.dragSteps } : params - if (method === 'mouseMove') nativeParams.useSystemPointer = mouseControl.allowed(agent) + if (method === 'mouseMove' && mouseControl) nativeParams.useSystemPointer = mouseControl.allowed(agent) const requestNative = () => client.request(method, nativeParams, signal, execution.callId) if (method === 'screenshot') { const { attachments, target } = await screenshotStore(ctx, execution) @@ -310,7 +310,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) signal.throwIfAborted() const operation = (async () => { if (request.method === 'requestMouseControl') { - if (!backend.capabilities.methods.includes(request.method)) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') + if (!mouseControl) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') return mouseControl.request(agent, signal) } const previous = clients.get(agent) From 3af5496e14cb9b6b99d1b1e47faa46e0cd1f45df Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 16:04:36 +0800 Subject: [PATCH 39/81] fix(cua): reserve mouse permission requests for the main task --- ...cua-hover-routes-and-coordinates.i18n.yaml | 4 +- ...-10-13-cua-hover-routes-and-coordinates.md | 2 +- ...-13-cua-hover-routes-and-coordinates.zh.md | 2 +- .../cua-hover-ax-only/guide.i18n.yaml | 8 +-- .../v0.2.2-rc.1/cua-hover-ax-only/guide.md | 4 +- .../v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md | 4 +- packages/experimental/cua-macos/README.md | 4 +- packages/experimental/cua-macos/README.zh.md | 4 +- .../native/Sources/CuaNative/AXWorker.swift | 4 -- .../cua-macos/native/tests/Fixture.swift | 3 + .../cua-macos/native/tests/e2e.py | 12 +++- .../experimental/cua-macos/src/guidance.ts | 2 +- .../tests/provider-lifecycle.spec.ts | 57 ++++++++++++++++--- packages/experimental/cua/src/guidance.ts | 2 +- packages/experimental/cua/src/locales.ts | 16 +++--- .../experimental/cua/src/mouse-control.ts | 14 +++-- packages/experimental/cua/src/types.ts | 5 +- 17 files changed, 99 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml index f6546d2ce8..7681ea3630 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml @@ -9,8 +9,8 @@ en: f96bf1ebc0719fb0 zh: e9a1144f0e76572d /agent-note-background-hover-and-screenshot-coordinates/decision: - en: 9e06d78eba10770d - zh: 25e985aa49f75dd8 + en: 93631d45c7ecfb4c + zh: 77a08aafb1115ba1 /agent-note-background-hover-and-screenshot-coordinates/alternatives-considered: en: 5fc4beaf79761c50 zh: 4948333b0020be28 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md index a1f0a7fac1..1f5c18ce74 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md @@ -10,7 +10,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer. ## Decision -By default, native hover uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. After the user approves `cua.requestMouseControl()`, each `mouseMove` in that session uses the system pointer until DSH restarts. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). +By default, native hover uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. After the user approves `cua.requestMouseControl()`, each `mouseMove` in that session uses the system pointer until DSH restarts. This lets a caller reach pointer-tracking controls even when the same target also advertises AX alternate UI. The Host passes the session decision with each move. Physical movement leaves the visibility of previously revealed controls to the application, so switching routes cannot dismiss a target before dispatch. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for `click`, `mouseMove`, `scroll` and `drag`; it does not apply to opaque element IDs. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md index f079ff736f..d1a14ee9e4 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -默认情况下,原生悬停会寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。用户同意 `cua.requestMouseControl()` 后,本会话的每次 `mouseMove` 都使用系统光标,重启 DSH 后授权失效。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 +默认情况下,原生悬停会寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。用户同意 `cua.requestMouseControl()` 后,本会话的每次 `mouseMove` 都使用系统光标,重启 DSH 后授权失效。有些控件既支持 AX 备用界面,又跟踪真实鼠标。Host 在每次悬停请求中传入本会话的授权结果,让调用方能够触发后者。切换到系统鼠标后,由应用决定是否收起先前展开的控件,以免派发前目标消失。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 面向模型的坐标默认使用返回截图中的像素。`click`、`mouseMove`、`scroll` 和 `drag` 传入 `relative: true` 时接受 0~999 的千分比整数,仅用于坐标目标。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml index cbce06f055..2f886f19df 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 6833a7851486f10d zh: 22983f326fb725ce /macos-hover-keeps-the-system-pointer-in-place-by-default/change: - en: 55c3070b24b4ad39 - zh: 2555d127b0f4e4a0 + en: 2369a7daf049311a + zh: 2ad1bfb579be4426 /macos-hover-keeps-the-system-pointer-in-place-by-default/migration: - en: bf3f886ad344f12a - zh: c877a616d4e147fb + en: 7c89c67c9acefd25 + zh: 54a0064c30af043c diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md index a7d03dc869..733a8a7061 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md @@ -9,10 +9,10 @@ English | [中文](guide.zh.md) ## Change -On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that advertises both `AXShowAlternateUI` and `AXShowDefaultUI`. Without that route, previous versions moved the visible system cursor. The default now returns `HOVER_UNAVAILABLE` before sending input. +On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that advertises both `AXShowAlternateUI` and `AXShowDefaultUI`. Without that route, previous versions moved the visible system cursor. The default now returns `HOVER_UNAVAILABLE` before sending input. Moving the pointer requires the user’s approval for the current session. ## Migration 1. For an available control, use its advertised action or click it directly. Use browser tools for web content that requires page hover. -2. Remove `systemPointerHover` from the macOS provider entry in your profile’s `cordis.patch.yml`. If you set `helperPath`, rebuild that helper to support the per-request `useSystemPointer` field. Restart the profile. +2. If your profile sets `helperPath`, rebuild the custom helper to support the per-request `useSystemPointer` field and restart the profile. 3. When the task requires a physical hover, call `cua.requestMouseControl()`. After the user approves, `win.mouseMove` on an unobscured foreground target returns `hoverRoute: "system-pointer"` and `systemPointerMoved: true`. This grant expires when DSH restarts. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md index 17064261b3..84ac6cbe2b 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md @@ -9,10 +9,10 @@ description: "macOS 的 mouseMove 默认要求控件支持 AX 备用界面,否 ## 变更 -在 macOS 上,`win.mouseMove(target)` 会查找已启用且同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的控件或祖先。找不到时,旧版会移动系统光标,新版默认在发送输入前返回 `HOVER_UNAVAILABLE`。 +在 macOS 上,`win.mouseMove(target)` 会查找已启用且同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的控件或祖先。找不到时,旧版会移动系统光标,新版默认在发送输入前返回 `HOVER_UNAVAILABLE`。移动系统光标需要用户单独授权当前会话。 ## 迁移 1. 对已经可用的控件,可执行它声明的动作或直接点击。需要网页悬停时可用浏览器工具。 -2. 从 profile 的 `cordis.patch.yml` 中移除 macOS 提供方配置项 `systemPointerHover`。如果设置了 `helperPath`,请重新构建该 helper,使其支持按请求读取 `useSystemPointer` 字段,然后重启 profile。 +2. 如果 profile 设置了 `helperPath`,请重新构建该 helper,使其支持按请求读取 `useSystemPointer` 字段,然后重启 profile。 3. 任务确实需要移动鼠标时,调用 `cua.requestMouseControl()`。用户同意后,对前台未受遮挡的目标调用 `win.mouseMove`,结果为 `hoverRoute: "system-pointer"` 和 `systemPointerMoved: true`。重启 DSH 后授权失效。 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 3190f8561d..8e269f5529 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -185,7 +185,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -210,7 +210,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 87c9fb046f..e68fa5b402 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -185,7 +185,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -210,7 +210,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift index c44b4078af..7602eb30d4 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift @@ -391,10 +391,6 @@ actor AXWorker { if let target = hit, try ax().windowID(target) != window.windowID { hit = nil } return try moveUsingAX(hit, windowID: window.windowID, handle: handle) } - if let hover = hovers[handle] { - _ = try hover.move(to: nil, in: window.windowID, access: ax()) - hovers.removeValue(forKey: handle) - } return .mouseMove(route, point) } var clickRoute = route diff --git a/packages/experimental/cua-macos/native/tests/Fixture.swift b/packages/experimental/cua-macos/native/tests/Fixture.swift index 930f7df5d2..d7f5bc21a8 100644 --- a/packages/experimental/cua-macos/native/tests/Fixture.swift +++ b/packages/experimental/cua-macos/native/tests/Fixture.swift @@ -37,6 +37,7 @@ class DragPad: NSView { } } final class AlternateHoverPad: DragPad { + private(set) var defaultUIRequests = 0 override func isAccessibilityElement() -> Bool { true } override func accessibilityRole() -> NSAccessibility.Role? { .group } override func accessibilityLabel() -> String? { "Alternate hover controls" } @@ -44,6 +45,7 @@ final class AlternateHoverPad: DragPad { hoverButton.isHidden = false; changed?(); return true } override func accessibilityPerformShowDefaultUI() -> Bool { + defaultUIRequests += 1 hoverButton.isHidden = true; changed?(); return true } } @@ -212,6 +214,7 @@ final class Fixture: NSObject, NSApplicationDelegate, NSTextFieldDelegate { "rowCount": rowCount, "primaryWindowId": window.windowNumber, "dragDownCount": dragPad.dragDownCount, "dragCount": dragPad.dragCount, "dragPoint": [dragPad.lastPoint.x, dragPad.lastPoint.y], "hoverVisible": !dragPad.hoverButton.isHidden, "hoverClicks": dragPad.hoverClicks, + "defaultUIRequests": (dragPad as? AlternateHoverPad)?.defaultUIRequests ?? 0, "cursorSamples": cursorSamples, "cursorChanges": cursorChanges, "overlayMode": overlayMode, "overlayWindowId": inputOverlay?.windowNumber ?? 0, "overlayOnScreen": overlayOnScreen, "hoverHitWindow": hoverHitWindow, diff --git a/packages/experimental/cua-macos/native/tests/e2e.py b/packages/experimental/cua-macos/native/tests/e2e.py index c5b385189a..5eeeaac59b 100644 --- a/packages/experimental/cua-macos/native/tests/e2e.py +++ b/packages/experimental/cua-macos/native/tests/e2e.py @@ -220,7 +220,7 @@ def main(): return result def check_hover(): - if not args.alternate_hover: + if not args.alternate_hover or args.input_overlays: call("activate", owner) current = call("getState", primary_params) window_element = next(element for element in current["elements"] if element["role"] == "AXWindow") @@ -275,7 +275,17 @@ def main(): after = wait_state(lambda actual: actual["cursorSamples"] >= before["cursorSamples"] + 2, "Pointer observation did not advance") assert after["cursorChanges"] == before["cursorChanges"], "Rejected hover moved the system pointer" + if args.alternate_hover: + assert before["hoverVisible"] and after["hoverVisible"], "Rejected physical hover dismissed AX controls" + assert after["defaultUIRequests"] == before["defaultUIRequests"], after cycle_overlay(0) + if args.alternate_hover: + state = call("getState", primary_params) + child = button_target(state, "Use native hover details") + result = call("mouseMove", {**child, "windowId": primary["windowId"], "useSystemPointer": True}) + assert result["hoverRoute"] == "system-pointer" and result["systemPointerMoved"], result + current = json.loads(state_path.read_text()) + assert current["hoverVisible"] and current["defaultUIRequests"] == before["defaultUIRequests"], current mouse_move({**owner, "windowId": primary["windowId"], "x": 10, "y": 40}) wait_state(lambda actual: not actual["hoverVisible"], "Hover did not exit after removing the overlay") diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index 5a340f045f..85272e3371 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -5,7 +5,7 @@ import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' export const PLATFORM_GUIDANCE = 'macOS: accessibility tree operations are available at app or window level. Simulated keyboard input is available at app level. Simulated mouse input is available at window level. For pointer input on a sheet, select its window ID with app.getWindow(id). For menu items outside a window, use app.invoke(id). findApps searches /Applications, /System/Applications, and ~/Applications. Use the Computer Use permission page to grant the helper application Accessibility and Screen Recording permissions in System Settings. Native window and AX bounds use macOS points, while delivered screenshots carry their own pixel dimensions. show does not switch Spaces, and Stage Manager can present reduced window geometry; explain any required foreground step before calling app.activate() or performing an advertised AXRaise action.' const methodDetails = new Map([ - ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured.'], + ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again.'], ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 922078987a..fe32ca0774 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -884,8 +884,9 @@ it('moves only for the consenting session, without granting application access o const ask = vi.fn((id: string) => ({ answers: [{ id, selected: ['Allow for this session'] }] })) f.ctx.on('user-questions/request', async ({ questions }) => ask(questions[0]!.id)) const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } - native.request.mockResolvedValueOnce({ appHandle: 'app-one', name: 'Fixture', running: true, - authorization: { id: 'a'.repeat(64), name: 'Fixture', persistable: false } }) + const application = { appHandle: 'app-one', name: 'Fixture', running: true, + authorization: { id: 'a'.repeat(64), name: 'Fixture', persistable: false } } + native.request.mockResolvedValueOnce(application) expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false) const move = async (useSystemPointer: boolean) => { expect((await f.execute('mouseMove', target)).isError).toBe(false) @@ -899,19 +900,24 @@ it('moves only for the consenting session, without granting application access o const otherCall = (method: string, params: Record) => executeToolFixture(f.ctx, { agent: other.agent, signal: new AbortController().signal, callId: ToolCallId(method), name: 'cua', arguments: { method, params }, }) - expect((await otherCall('requestMouseControl', {})).value).toBe(true) - expect((await otherCall('mouseMove', target)).isError).toBe(true) + expect(JSON.stringify((await otherCall('mouseMove', target)).content)).toContain('APP_ACCESS_REQUIRED') + native.request.mockResolvedValueOnce(application) + expect((await otherCall('getApp', { selector: 'Fixture' })).isError).toBe(false) + ask.mockImplementationOnce(id => ({ answers: [{ id, selected: ['Deny'] }] })) + expect((await otherCall('requestMouseControl', {})).value).toBe(false) + expect((await otherCall('mouseMove', target)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...target, useSystemPointer: false }, expect.any(AbortSignal), expect.any(String)) + await move(true) await f.provider.dispose() await f.ctx.plugin(NativeProvider, config) expect((await f.execute('requestMouseControl')).value).toBe(true) - expect(ask.mock.calls.map(([id]) => id)).toEqual(['cua-app-access', 'cua-mouse-control', 'cua-mouse-control', 'cua-mouse-control']) + expect(ask.mock.calls.map(([id]) => id)).toEqual(['cua-app-access', 'cua-mouse-control', 'cua-app-access', 'cua-mouse-control', 'cua-mouse-control']) }) -it.each(['Deny', 'custom', 'cancel'] as const)('remembers a %s answer without moving the pointer or asking again', async (answer) => { +it.each(['Deny', 'custom'] as const)('remembers a %s answer without moving the pointer or asking again', async (answer) => { const f = await setup() await f.ctx.plugin(UserQuestions) const ask = vi.fn(() => { - if (answer === 'cancel') throw new UserQuestionError('User dismissed the request', 'ASK_CANCELLED') return { answers: [{ id: 'cua-mouse-control', selected: [answer === 'custom' ? 'Allow for this session' : answer], ...(answer === 'custom' ? { custom: 'maybe later' } : {}) }] } }) @@ -922,6 +928,39 @@ it.each(['Deny', 'custom', 'cancel'] as const)('remembers a %s answer without mo expect(native.request).not.toHaveBeenCalled() }) +it('allows a fresh request after dismissal without authorizing the first one', async () => { + const f = await setup() + await f.ctx.plugin(UserQuestions) + const ask = vi.spyOn(f.ctx.userQuestions, 'ask') + .mockRejectedValueOnce(new UserQuestionError('User dismissed the request', 'ASK_CANCELLED')) + .mockResolvedValueOnce({ answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }] }) + const dismissed = await f.execute('requestMouseControl') + expect(dismissed.isError).toBe(true) + expect(JSON.stringify(dismissed.content)).toContain('MOUSE_APPROVAL_CANCELLED') + expect((await f.execute('requestMouseControl')).value).toBe(true) + expect(ask).toHaveBeenCalledTimes(2) + expect(native.request).not.toHaveBeenCalled() +}) + +it('refuses delegated mouse control before asking the user even when the parent has approval', async () => { + const f = await setup() + await f.ctx.plugin(UserQuestions) + const ask = vi.spyOn(f.ctx.userQuestions, 'ask').mockResolvedValue({ + answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }], + }) + expect((await f.execute('requestMouseControl')).value).toBe(true) + ask.mockClear() + const { agent } = await f.ctx.agents.create({ sessionId: SessionId('delegated-mouse-session'), + meta: { origin: 'subagent', parentSession: f.handle.agent.id } }) + const refused = await executeToolFixture(f.ctx, { agent, signal: new AbortController().signal, + callId: ToolCallId('delegated-mouse-control'), name: 'cua', arguments: { method: 'requestMouseControl', params: {} } }) + expect(refused.isError).toBe(true) + expect(JSON.stringify(refused.content)).toContain('MOUSE_CONTROL_UNAVAILABLE') + expect(JSON.stringify(refused.content)).toContain('report that step in your final result for the main task') + expect(ask).not.toHaveBeenCalled() + expect(native.request).not.toHaveBeenCalled() +}) + it('drops late approval after cancellation and admits only one pending question', async () => { const f = await setup() expect((await f.execute('requestMouseControl')).isError).toBe(true) @@ -938,7 +977,7 @@ it('drops late approval after cancellation and admits only one pending question' const controller = new AbortController() const first = f.execute('requestMouseControl', {}, controller.signal) await entered.promise - expect((await f.execute('requestMouseControl')).isError).toBe(true) + expect(JSON.stringify((await f.execute('requestMouseControl')).content)).toContain('MOUSE_CONTROL_PENDING') controller.abort() release.resolve(undefined) await Promise.allSettled([first]) @@ -958,7 +997,7 @@ it('keeps provider-specific pointer flags and questions off providers without mo keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, }) } }) - expect((await f.execute('requestMouseControl')).isError).toBe(true) + expect(JSON.stringify((await f.execute('requestMouseControl')).content)).toContain('UNSUPPORTED_OPERATION') await selectApplication(f) const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } expect((await f.execute('mouseMove', target)).isError).toBe(false) diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index 5f0abd1dac..5787dd1667 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -8,7 +8,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. diff --git a/packages/experimental/cua/src/locales.ts b/packages/experimental/cua/src/locales.ts index 3833690158..7c2cba49eb 100644 --- a/packages/experimental/cua/src/locales.ts +++ b/packages/experimental/cua/src/locales.ts @@ -117,14 +117,14 @@ export const mouseControlDisplay = { pt: 'Permitir que o agente mova o ponteiro do mouse?', }, detail: { - en: 'Hovering will move your pointer and leave it at the target, even while you are using the mouse. This permission applies only to this session and expires when DSH restarts.', - zh: '智能体悬停时会移动你的鼠标指针,并将它留在目标位置,即使你正在使用鼠标。授权仅限当前会话,重启 DSH 后失效。', - ja: 'ホバーすると、マウスを使用中でもポインタが移動し、移動先に残ります。許可はこのセッションに限り、DSH を再起動すると失効します。', - es: 'Al pasar sobre un elemento, el puntero se moverá y quedará allí aunque estés usando el ratón. El permiso solo se aplica a esta sesión y caduca al reiniciar DSH.', - fr: 'Lors d’un survol, le pointeur se déplace et reste sur la cible, même si vous utilisez la souris. Cette autorisation vaut pour cette session et expire au redémarrage de DSH.', - de: 'Beim Überfahren eines Ziels bewegt sich der Zeiger und bleibt dort, auch wenn Sie die Maus verwenden. Die Erlaubnis gilt nur für diese Sitzung und erlischt beim Neustart von DSH.', - ru: 'При наведении указатель переместится и останется на цели, даже если вы пользуетесь мышью. Разрешение действует только в этом сеансе до перезапуска DSH.', - pt: 'Ao passar sobre um elemento, o ponteiro se moverá e ficará no destino, mesmo enquanto você usa o mouse. A permissão vale apenas para esta sessão e expira ao reiniciar o DSH.', + en: 'Hovering will move your pointer and leave it at the target, even while you are using the mouse. This permission applies only to this session until DSH restarts. If you decline, this session will not ask again before restart.', + zh: '智能体悬停时会移动你的鼠标指针,并将它留在目标位置,即使你正在使用鼠标。授权仅限当前会话,重启 DSH 后失效。如果拒绝,重启前本会话不会再次询问。', + ja: 'ホバーすると、マウスを使用中でもポインタが移動し、移動先に残ります。許可はこのセッションに限り、DSH を再起動すると失効します。拒否すると、再起動までこのセッションでは再度確認しません。', + es: 'Al pasar sobre un elemento, el puntero se moverá y quedará allí aunque estés usando el ratón. El permiso solo se aplica a esta sesión y caduca al reiniciar DSH. Si lo deniegas, esta sesión no volverá a preguntar hasta que reinicies DSH.', + fr: 'Lors d’un survol, le pointeur se déplace et reste sur la cible, même si vous utilisez la souris. Cette autorisation vaut pour cette session et expire au redémarrage de DSH. Si vous refusez, cette session ne redemandera pas avant le redémarrage.', + de: 'Beim Überfahren eines Ziels bewegt sich der Zeiger und bleibt dort, auch wenn Sie die Maus verwenden. Die Erlaubnis gilt nur für diese Sitzung und erlischt beim Neustart von DSH. Wenn Sie ablehnen, fragt diese Sitzung bis zum Neustart nicht erneut.', + ru: 'При наведении указатель переместится и останется на цели, даже если вы пользуетесь мышью. Разрешение действует только в этом сеансе до перезапуска DSH. Если вы откажете, запрос в этом сеансе не повторится до перезапуска.', + pt: 'Ao passar sobre um elemento, o ponteiro se moverá e ficará no destino, mesmo enquanto você usa o mouse. A permissão vale apenas para esta sessão e expira ao reiniciar o DSH. Se você recusar, esta sessão não perguntará novamente até a reinicialização.', }, approve: applicationAccessDisplay.approve, } diff --git a/packages/experimental/cua/src/mouse-control.ts b/packages/experimental/cua/src/mouse-control.ts index 947482766f..595cef0f0b 100644 --- a/packages/experimental/cua/src/mouse-control.ts +++ b/packages/experimental/cua/src/mouse-control.ts @@ -21,14 +21,17 @@ export class MouseControl { } /** - * Ask once for the Session. Simultaneous requests cannot open additional questions. + * Ask the main task for a Session decision. Simultaneous requests cannot open additional questions. * @param agent Live Agent that owns the Session. * @param signal Tool lifetime. An aborted tool cannot grant permission. * @returns True only when the user chose the offered approval. */ async request(agent: Agent, signal: AbortSignal): Promise { + if (agent.session.header.origin === 'subagent') { + throw new NativeError('MOUSE_CONTROL_UNAVAILABLE', "Delegated tasks cannot move the user's pointer. Use AX hover, click, or performAction. If the task requires a physical hover, report that step in your final result for the main task.") + } const decision = this.decisions.get(agent.id) - if (decision === 'asking') throw new NativeError('MOUSE_CONTROL_PENDING', 'A mouse control request is already awaiting an answer in this session') + if (decision === 'asking') throw new NativeError('MOUSE_CONTROL_PENDING', 'Wait for the pending mouse control request in this session before continuing') if (decision !== undefined) return decision const questions = this.ctx.get('userQuestions') if (!questions) throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', 'No human answerer is available for mouse control') @@ -49,11 +52,10 @@ export class MouseControl { this.decisions.set(agent.id, granted) return granted } catch (error) { - if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { - this.decisions.set(agent.id, false) - return false - } this.decisions.delete(agent.id) + if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { + throw new NativeError('MOUSE_APPROVAL_CANCELLED', 'The mouse control request was dismissed. Continue without mouse control unless the user asks to reconsider', { cause: error }) + } throw error } } diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index 541dcdd070..77923d64be 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -247,8 +247,9 @@ export interface CuaApp extends CuaSurface { /** Native app API; its caller owns global bindings and variable persistence. */ export interface CuaApi { /** - * Ask to move the user's pointer with mouseMove for this session until restart. - * @returns True only after explicit approval. A refusal is remembered for the session. + * Ask to move the user's pointer with mouseMove for this session until restart. Only the main task can ask. + * @returns True on approval, false on refusal. Both decisions last until restart for this session. + * @throws If the user dismisses the question without deciding or another request is pending. */ requestMouseControl(): Promise /** From 30ccf8a8ed447c44a98397e7a384249f77e80214 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 16:05:53 +0800 Subject: [PATCH 40/81] test(cua): refresh mouse approval guidance snapshots --- snapshots/session/cua-native/system-prompt.expected.md | 4 ++-- snapshots/session/cua-ptc/system-prompt.expected.md | 4 ++-- .../session/ptc-script-retained/system-prompt.expected.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index e7132c8c0f..2fd5c3df3d 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -43,7 +43,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -68,7 +68,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 45154f9179..b1fae81bcf 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -45,7 +45,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -70,7 +70,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index b1769c75dc..8780b966c7 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -45,7 +45,7 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Returns true only on approval. Avoid asking unless the task requires a physical hover. +- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -70,7 +70,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, mouseMove uses the system pointer for this session until DSH restarts. If the user declines, continue without mouse control. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. From ed5d4d23de059c10663ae7bbee96caa68fabe387 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 16:10:38 +0800 Subject: [PATCH 41/81] test(cua): use the shared channel fixture for providers without pointer control --- .../experimental/cua-macos/tests/provider-lifecycle.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 23925fe847..52202a0774 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -1083,7 +1083,7 @@ it('keeps provider-specific pointer flags and questions off providers without mo await f.provider.dispose() await f.ctx.plugin({ name: 'fixture-window-provider', inject: NativeProvider.inject, apply(ctx: Context) { installCua(ctx, CuaConfig(config), { - helperPath: config.helperPath, createChannel() { throw new Error('Unexpected native channel') }, + helperPath: config.helperPath, createChannel: native.connect, guidance: '', permissionParameters: () => ({}), capabilities: { platform: 'fixture', methods: ['getApp', 'mouseMove'], appLevelAccessibility: true, keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, From d8d6081f44844ec56487cd6029c471b8435256ec Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:22:21 +0800 Subject: [PATCH 42/81] perf(ui-sidebar-files): give the navigator's covering rules a subject The two rules that hide a row's body under a filled navigator started with `:has(`, so the browser treated every element as a possible subject and restyled the whole document after any DOM change. Both rows that host the navigator now carry data-files-navigator-row, which the rules name. --- .../client/ui-sidebar-documentpreview/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar-documentpreview/README.md | 2 +- packages/client/ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/PreviewFrame.tsx | 2 +- .../tests/text-preview.client.spec.tsx | 6 ++++-- .../client/ui-sidebar-files/src/client/FileNavigator.tsx | 6 +++++- .../ui-sidebar-files/src/client/FilesBody.module.css | 8 +++++--- packages/client/ui-sidebar-files/src/client/FilesBody.tsx | 2 +- .../ui-sidebar-files/tests/files-body.client.spec.tsx | 2 ++ 9 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 99d60b54c9..91373281f1 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -15,8 +15,8 @@ en: 0dd8c3a22e28aacf zh: c27c11bf4b5cceba /deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers: - en: 5686f49d98437f49 - zh: 40c154f8cc7b8d14 + en: 691249b09395eae1 + zh: e2ae49df2ebe130e /deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses: en: 57942270e23d3a8e zh: 6617124e652b3f67 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 4e87ae3aed..6453a874e8 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -31,7 +31,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo Image drag actions arrive through the image entry’s inject face from [ui-file-drag](../ui-file-drag/README.md). - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, a whole-address file-resource pattern, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls and before the navigator toggle, at the same position in every state that shows the header so it is not remounted when the state changes, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). The preview directly renders [`ui-sidebar-files`](../ui-sidebar-files/README.md#the-navigator)'s Session-scoped Factories: `sidebar.files.navigator` after the body in its content row with `{ useTabInfo, page: false, onOpen }`, where `onOpen(address)` calls `tab.actions.openResource(address, { replaceTab: true })`, and `sidebar.files.navigator.toggle` at the header's end with `{ useTabInfo }`. Both stay mounted from loading to loaded in docked and floating panes; unregistered Factories render nothing. The navigator owns its width within the row's positioning box and stacking context, narrowing or overlaying the body without covering the floating pane's resize handle. A renderer layer handling Escape calls `preventDefault()` to keep the navigator from also closing. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the file tree's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls and before the navigator toggle, at the same position in every state that shows the header so it is not remounted when the state changes, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). The preview directly renders [`ui-sidebar-files`](../ui-sidebar-files/README.md#the-navigator)'s Session-scoped Factories: `sidebar.files.navigator` after the body in its content row, which carries `data-files-navigator-row`, with `{ useTabInfo, page: false, onOpen }`, where `onOpen(address)` calls `tab.actions.openResource(address, { replaceTab: true })`, and `sidebar.files.navigator.toggle` at the header's end with `{ useTabInfo }`. Both stay mounted from loading to loaded in docked and floating panes; unregistered Factories render nothing. The navigator owns its width within the row's positioning box and stacking context, narrowing or overlaying the body without covering the floating pane's resize handle. A renderer layer handling Escape calls `preventDefault()` to keep the navigator from also closing. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the file tree's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. `sourceLines` declares that a `text-pages` body exposes source-line anchors for [navigation](#navigation). Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index f2996fd813..3e846d1f63 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 图片拖放回调通过图片入口的注入接口从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,匹配完整文件资源地址的 pattern,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 -- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后、导航栏开关之前,位置在各状态间相同,因此状态切换时不会重新挂载;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。预览直接渲染 [`ui-sidebar-files`](../ui-sidebar-files/README.zh.md#the-navigator) 的 Session 作用域 Factory:`sidebar.files.navigator` 位于内容行的正文之后,传入 `{ useTabInfo, page: false, onOpen }`,其中 `onOpen(address)` 调用 `tab.actions.openResource(address, { replaceTab: true })`;`sidebar.files.navigator.toggle` 位于头部末端,传入 `{ useTabInfo }`。两者在停靠与浮动分栏中从加载到完成保持挂载;未注册的 Factory 不绘制内容。导航栏在内容行的定位框与层叠上下文内决定宽度,让正文变窄或覆盖正文,不遮挡浮窗缩放手柄。自行处理 Escape 的渲染器浮层需调用 `preventDefault()`,避免同时关闭导航栏。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与文件树右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后、导航栏开关之前,位置在各状态间相同,因此状态切换时不会重新挂载;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。预览直接渲染 [`ui-sidebar-files`](../ui-sidebar-files/README.zh.md#the-navigator) 的 Session 作用域 Factory:`sidebar.files.navigator` 位于内容行的正文之后(该行带有 `data-files-navigator-row`),传入 `{ useTabInfo, page: false, onOpen }`,其中 `onOpen(address)` 调用 `tab.actions.openResource(address, { replaceTab: true })`;`sidebar.files.navigator.toggle` 位于头部末端,传入 `{ useTabInfo }`。两者在停靠与浮动分栏中从加载到完成保持挂载;未注册的 Factory 不绘制内容。导航栏在内容行的定位框与层叠上下文内决定宽度,让正文变窄或覆盖正文,不遮挡浮窗缩放手柄。自行处理 Escape 的渲染器浮层需调用 `preventDefault()`,避免同时关闭导航栏。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与文件树右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。`sourceLines` 声明 `text-pages` 正文为[导航](#navigation)提供源码行锚点。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx b/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx index 6ee191a3fe..5b529fffee 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx @@ -73,7 +73,7 @@ export function PreviewFrame({ {handoff}
-
+
{children}
diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx index fb24a887b4..8e0c2b089c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx @@ -861,6 +861,8 @@ describe('TextPreview — workspace navigator Factories', () => { return { toggle: header?.lastElementChild?.getAttribute('data-navigator-factory'), navigator: row?.lastElementChild?.getAttribute('data-navigator-factory'), + // The navigator's filled layout hides the body of the row this attribute names. + navigatorRow: row?.hasAttribute('data-files-navigator-row'), } } @@ -876,7 +878,7 @@ describe('TextPreview — workspace navigator Factories', () => { ]) expect(view.container.querySelector('[data-textpreview-state="loading"]')).not.toBeNull() expect(seats(view.container)).toEqual({ - toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', + toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', navigatorRow: true, }) const navigator = view.container.querySelector('[data-textpreview-content] > [data-navigator-factory]')! const toggle = view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild @@ -890,7 +892,7 @@ describe('TextPreview — workspace navigator Factories', () => { await settle() expect(view.container.querySelector('[data-textpreview-state="text"]')).not.toBeNull() expect(seats(view.container)).toEqual({ - toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', + toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', navigatorRow: true, }) expect(view.container.querySelector('[data-textpreview-content] > [data-navigator-factory]')).toBe(navigator) expect(view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild).toBe(toggle) diff --git a/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx b/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx index dfe53b637f..dac8bb7f88 100644 --- a/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx +++ b/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx @@ -41,7 +41,11 @@ export interface FileNavigatorInput { declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotFactoryMap { - /** One pane's workspace tree with its width and show/hide state. */ + /** + * One pane's workspace tree with its width and show/hide state. The host renders it as a direct child of + * the content row it shares with the body, and that row carries `data-files-navigator-row`, which the + * filled layout needs to hide the covered body. + */ 'sidebar.files.navigator': { scope: 'session' props: FileNavigatorInput diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css index 4bddc11bb1..315d5afae8 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css @@ -74,14 +74,16 @@ border-left: 0; } -/* Covered content stays laid out but cannot receive keyboard focus. */ -:has(> .navigator.fill) > :not(.navigator) { +/* Covered content stays laid out but cannot receive keyboard focus. The rule + names its row through `data-files-navigator-row`: a `:has()` without a + subject makes the browser restyle the whole document after any DOM change. */ +[data-files-navigator-row]:has(> .navigator.fill) > :not(.navigator) { visibility: hidden; } /* navigator-layout `FULLSCREEN_QUERY`: every layout fills the row. */ @media (width < 768px) { - :has(> .navigator:not(.wholePage)) > :not(.navigator) { + [data-files-navigator-row]:has(> .navigator:not(.wholePage)) > :not(.navigator) { visibility: hidden; } diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx index f7f5ae385f..0f6b26d7f6 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -113,7 +113,7 @@ export function FilesBody(props: FilesBodyProps): ReactNode { {renderSlot('sidebar.right.tab.files.actions', { absolutePath: cwd })}
-
{navigator}
+
{navigator}
) } diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx index a8ef76a271..2cd0aa3d5a 100644 --- a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -56,6 +56,8 @@ describe('FilesBody', () => { expect([...header.querySelectorAll('button:not([data-files-auto-refresh])')].map(button => button.getAttribute('aria-label'))) .toEqual([zh.reload, zh['search.placeholder']]) const content = page.querySelector('[data-files-content]')! + // The filled layout's rule finds the row the navigator covers by this attribute. + expect(content.hasAttribute('data-files-navigator-row')).toBe(true) expect(content.children).toHaveLength(1) const navigator = content.firstElementChild! expect(navigator.getAttribute('data-files-navigator')).toBe(PANE) From 9b456b4e9353e02027b40818ccd26f6cfc5f2285 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:22:24 +0800 Subject: [PATCH 43/81] perf(forge): compare from the recorded base while confirming the head Status snapshots carry the base commit the host recorded with the head (baseSha). ForgeService.diff passes it to the provider when the latest status names the requested head, and forge-github starts the compare read from it while its GraphQL read confirms the head; a stale head or another base cancels and joins the early read. --- docs/subsystems/forge.md | 13 +++-- docs/subsystems/forge.zh.md | 13 +++-- .../forge-github/README.i18n.yaml | 8 +-- packages/experimental/forge-github/README.md | 4 +- .../experimental/forge-github/README.zh.md | 4 +- .../experimental/forge-github/src/graphql.ts | 4 +- .../experimental/forge-github/src/provider.ts | 44 +++++++++++----- .../forge-github/tests/fixtures.ts | 1 + .../forge-github/tests/graphql.spec.ts | 1 + .../forge-github/tests/provider.spec.ts | 51 ++++++++++++++++++- .../tests/loader-composition.spec.ts | 1 + packages/experimental/forge/README.i18n.yaml | 4 +- packages/experimental/forge/README.md | 2 +- packages/experimental/forge/README.zh.md | 2 +- packages/experimental/forge/src/index.ts | 7 ++- packages/experimental/forge/src/types.ts | 10 +++- .../experimental/forge/tests/watch.spec.ts | 16 ++++++ .../extensions/tool-cordis/src/api-catalog.ts | 6 +-- 18 files changed, 152 insertions(+), 39 deletions(-) diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index b5156c101f..208c6a5d9f 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -71,9 +71,10 @@ interface ForgeProvider { /** * Read the changes of a change request at head commit `headSha` as the host computes them, as one unified * patch with `a/` and `b/` path prefixes. A patch is returned only when it describes `headSha`; once the - * head is another commit, the read resolves to a `stale` failure. + * head is another commit, the read resolves to a `stale` failure. `base`, when given, is the `baseSha` the + * latest status recorded with `headSha`; a provider may start reading from it before it confirms the head. */ - diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise /** Read the text of `path` at `commit` in the change request's repository. */ fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise /** Build the web page of `path` at `commit` in the change request's repository. */ @@ -199,6 +200,11 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** + * Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff; + * omitted when the provider does not report it. + */ + readonly baseSha?: string /** Lines the change request adds. */ readonly additions: number /** Lines the change request deletes. */ @@ -907,7 +913,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise + diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise /** Read the text of `path` at `commit` in the change request's repository. */ fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise /** Build the web page of `path` at `commit` in the change request's repository. */ @@ -199,6 +200,11 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** + * Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff; + * omitted when the provider does not report it. + */ + readonly baseSha?: string /** Lines the change request adds. */ readonly additions: number /** Lines the change request deletes. */ @@ -907,7 +913,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise --repo //`. Merging calls `PUT repos///pulls//merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`. -`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. +`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. Each status snapshot carries `baseRefOid` as `baseSha`; given it, `diff` starts this comparison while the GraphQL query confirms the head, and a stale head or another base commit cancels the early comparison and waits for it to end before `diff` returns. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. @@ -100,7 +100,7 @@ None. Provider output reaches only host-side watchers. - **Batch deadline** — continuation pages of a pull request with many review threads lengthen its host's polling batch; a batch that outlives `ctx.forge`'s `fetchTimeoutMs` reports `unavailable` for every pull request in it, not only for the one with many threads. - **Rewritten base branch** — the diff starts from the merge base of the head and the base commit GitHub recorded at the pull request's last update. After the base branch is force-pushed, that merge base can differ from the one GitHub's pull request view uses, so the patch can include or omit commits until the pull request is updated. - **No fork discovery** — head lookups exclude pull requests whose head is in another repository. -- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request. All count against the signed-in account’s rate limit. +- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request, and a second REST request when the base commit changed after the latest status. All count against the signed-in account’s rate limit. - **No merge queue** — a base branch that requires a merge queue refuses a direct merge, and the provider never queues one. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 205a8f0439..0851001833 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -46,7 +46,7 @@ kind: "package-reference" pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready --repo //`。合并经 `gh api` 调用 `PUT repos///pulls//merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。 -`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 +`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。每个状态快照把 `baseRefOid` 作为 `baseSha` 携带;拿到它时,`diff` 在 GraphQL 查询确认 head 的同时就开始这次比较,head 已过时或基准提交不同时会取消提前开始的比较并等它结束,然后才返回。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 @@ -100,7 +100,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 - **批次时限**——评审讨论很多的 pull request 的续页会拉长其主机的轮询批次;批次超过 `ctx.forge` 的 `fetchTimeoutMs` 时,批次内每个 pull request 都报告 `unavailable`,而不只是讨论很多的那个。 - **基准分支被改写**——diff 从 head 与 GitHub 在 pull request 最后一次更新时记录的基准提交的合并基准开始。基准分支被强制推送后,该合并基准可能不同于 GitHub pull request 页面使用的合并基准,因此在 pull request 更新前,补丁可能多出或缺少提交。 - **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。 -- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求。这些都计入已登录账号的速率配额。 +- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求;最新状态之后基准提交有变化时再多一次 REST 请求。这些都计入已登录账号的速率配额。 - **不支持合并队列**——要求合并队列的基准分支会拒绝直接合并,提供方也不会把变更请求加入队列。 diff --git a/packages/experimental/forge-github/src/graphql.ts b/packages/experimental/forge-github/src/graphql.ts index 75922515eb..92bcc4fbb2 100644 --- a/packages/experimental/forge-github/src/graphql.ts +++ b/packages/experimental/forge-github/src/graphql.ts @@ -32,7 +32,7 @@ const THREAD_FIELDS = 'pageInfo { hasNextPage endCursor } nodes { isResolved }' function pullRequestFields(threads: number): string { return `id number url title state isDraft updatedAt closedAt author { login } - headRefName headRefOid baseRefName additions deletions mergeable mergeStateStatus reviewDecision + headRefName headRefOid baseRefName baseRefOid additions deletions mergeable mergeStateStatus reviewDecision reviewThreads(first: ${threads}) { ${THREAD_FIELDS} } commits(last: 1) { nodes { commit { statusCheckRollup { id state contexts(first: ${PAGE_SIZE}) { ${CHECK_FIELDS} @@ -132,6 +132,7 @@ const pullRequestSchema = z.object({ headRefName: z.string(), headRefOid: z.string(), baseRefName: z.string(), + baseRefOid: z.string().refine(isCommit), additions: z.number().int().nonnegative(), deletions: z.number().int().nonnegative(), mergeable: z.string(), @@ -466,6 +467,7 @@ function toSnapshot( headBranch: node.headRefName, headSha: node.headRefOid, baseBranch: node.baseRefName, + baseSha: node.baseRefOid, additions: node.additions, deletions: node.deletions, mergeability: mergeability(node.mergeable), diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index 67e963e3d9..d94a277bb8 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -159,24 +159,44 @@ export class GitHubProvider implements ForgeProvider { * current head and the base commit GitHub recorded with it; a head other * than `headSha` returns `stale`. Otherwise the compare endpoint reads the * patch from the merge base of that base commit and `headSha`, as the pull - * request's own diff does, with both ends fixed. + * request's own diff does, with both ends fixed. Given the `base` the latest + * status recorded with `headSha`, the comparison from it runs while the + * GraphQL read confirms the head; a stale head or another base commit + * cancels that comparison and waits for it to end before the read returns. */ - async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { + async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise { const { host, owner, name } = checkedRepository(ref) const failure = this.hostFailure(host) if (failure !== undefined) return failure if (!isCommit(headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(headSha)}`) - const output = await this.read([ - 'api', 'graphql', '--hostname', host, - '-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`, - ], signal) - if (typeof output !== 'string') return output - const commits = mapPullRequestCommitsResponse(parseJson(output)) - if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' } - if (commits.head.toLowerCase() !== headSha.toLowerCase()) { - return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` } + if (base !== undefined && !isCommit(base)) throw new Error(`forge-github: invalid base commit ${JSON.stringify(base)}`) + const compare = async (from: string, operation: AbortSignal): Promise => + await readComparison(async (args, inner) => await this.read(args, inner), ref.repository, from, headSha, operation) + const speculation = new AbortController() + const early = base === undefined ? undefined : { base, patch: compare(base, AbortSignal.any([signal, speculation.signal])) } + // The early comparison is observed here, so a cancelled or unused one never rejects unhandled. + const settled = early?.patch.then(() => undefined, () => undefined) + const discard = async (): Promise => { + speculation.abort() + await settled + } + try { + const output = await this.read([ + 'api', 'graphql', '--hostname', host, + '-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`, + ], signal) + if (typeof output !== 'string') return output + const commits = mapPullRequestCommitsResponse(parseJson(output)) + if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' } + if (commits.head.toLowerCase() !== headSha.toLowerCase()) { + return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` } + } + if (early?.base.toLowerCase() === commits.base.toLowerCase()) return await early.patch + await discard() + return await compare(commits.base, signal) + } finally { + await discard() } - return await readComparison(async (args, operation) => await this.read(args, operation), ref.repository, commits.base, headSha, signal) } /** Read one file at a commit through the contents API in its raw form. */ diff --git a/packages/experimental/forge-github/tests/fixtures.ts b/packages/experimental/forge-github/tests/fixtures.ts index 66f2f47652..bc5d1297b0 100644 --- a/packages/experimental/forge-github/tests/fixtures.ts +++ b/packages/experimental/forge-github/tests/fixtures.ts @@ -54,6 +54,7 @@ export function pullRequestNode(number: number, overrides: Record { headBranch: 'topic-1', headSha: 'f'.repeat(40), baseBranch: 'master', + baseSha: 'b'.repeat(40), additions: 128, deletions: 14, mergeability: 'mergeable', diff --git a/packages/experimental/forge-github/tests/provider.spec.ts b/packages/experimental/forge-github/tests/provider.spec.ts index 1993c8cee6..d923d7425e 100644 --- a/packages/experimental/forge-github/tests/provider.spec.ts +++ b/packages/experimental/forge-github/tests/provider.spec.ts @@ -1,5 +1,5 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { GhOutputLimitError, GhTimeoutError, GhUnavailableError } from '../src/gh.ts' import type { GhOutput, GhRunner } from '../src/gh.ts' import { PULL_REQUEST_COMMITS_QUERY, THREADS_QUERY } from '../src/graphql.ts' @@ -446,6 +446,55 @@ describe('GitHub provider reads', () => { expect(gh.calls[1]?.at(-1)).toBe(`repos/octo/demo/compare/${base}...${head.toUpperCase()}`) }) + it('starts the comparison from the base the status recorded while it confirms the head', async () => { + const confirmed = Promise.withResolvers() + const gh = fakeGh(args => args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`) + ? confirmed.promise : output('diff --git a/x b/x\n')) + + const read = provider(gh.run).diff(pull(7), head, signal, base) + await vi.waitFor(() => { expect(gh.calls).toHaveLength(2) }) + expect(gh.calls.map(call => call.at(-1))).toContain(`repos/octo/demo/compare/${base}...${head}`) + confirmed.resolve(output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: base.toUpperCase() } } } })) + + await expect(read).resolves.toBe('diff --git a/x b/x\n') + expect(gh.calls).toHaveLength(2) + }) + + it('cancels the early comparison and waits for it when the head moved, the base differs, or the head read fails', async () => { + const moved = 'e'.repeat(40) + for (const [answer, expected, compares] of [ + [output({ data: { repository: { pullRequest: { headRefOid: pushed, baseRefOid: base } } } }), + { code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }, [base]], + [output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: moved } } } }), 'diff --git a/y b/y\n', [base, moved]], + [output('', 1, 'gh: Something went wrong (HTTP 502)'), { code: 'unavailable', message: 'gh: Something went wrong (HTTP 502)' }, [base]], + ] as const) { + const calls: string[][] = [] + const cancelled: string[] = [] + const run: GhRunner = async (args, operation) => { + calls.push([...args]) + if (args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)) return answer + const range = String(args.at(-1)) + if (range.includes(`${moved}...`)) return output('diff --git a/y b/y\n') + // The early comparison runs until the provider cancels it. + await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) }) + cancelled.push(range) + throw operation.reason + } + + await expect(provider(run).diff(pull(7), head, signal, base)).resolves.toEqual(expected) + expect(calls.map(call => String(call.at(-1))).filter(target => target.includes('/compare/')) + .map(target => target.split('/compare/')[1]?.split('...')[0])).toEqual(compares) + expect(cancelled).toEqual([`repos/octo/demo/compare/${base}...${head}`]) + } + }) + + it('refuses a base that is not a full commit object name before starting gh', async () => { + const gh = pinned() + + await expect(provider(gh.run).diff(pull(7), head, signal, 'HEAD~1')).rejects.toThrow('forge-github: invalid base commit "HEAD~1"') + expect(gh.calls).toEqual([]) + }) + it('reports a head that moved past the requested commit as stale without reading the diff', async () => { const gh = pinned({ head: pushed, base }) diff --git a/packages/experimental/forge-session/tests/loader-composition.spec.ts b/packages/experimental/forge-session/tests/loader-composition.spec.ts index 486e4f0635..f403e347a4 100644 --- a/packages/experimental/forge-session/tests/loader-composition.spec.ts +++ b/packages/experimental/forge-session/tests/loader-composition.spec.ts @@ -80,6 +80,7 @@ const node = (number) => ({ headRefName: number === 7 ? 'feat' : 'other', headRefOid: 'a'.repeat(40), baseRefName: 'main', + baseRefOid: 'e'.repeat(40), additions: 1, deletions: 0, mergeable: 'MERGEABLE', diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml index ce02168f4c..19fb0423c8 100644 --- a/packages/experimental/forge/README.i18n.yaml +++ b/packages/experimental/forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge/use-this-package: - en: 66ce801db0de565f - zh: 884973fad2ad2540 + en: 4b29b898061bcc4e + zh: fd37c2a8b06e7aaf /deepseek-ai-dsh-experimental-forge/understand-the-implementation: en: e193cb547c47069b zh: faa517522402ea41 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md index 8727e8cd2b..b316d57a78 100644 --- a/packages/experimental/forge/README.md +++ b/packages/experimental/forge/README.md @@ -36,7 +36,7 @@ Mount the registry with at least one provider package, such as the GitHub provid `markReady(ref, signal)` marks a draft ready for review, and `merge(ref, { method, headSha }, signal)` merges a change request only while its head is still `headSha`. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to undefined once the host accepted the change or to a `ForgeActionFailure`: `unauthenticated`, `stale` when the head moved past `headSha`, or `failed` with the host's reason. After either settles, a watched change request is polled at once. Snapshots carry `mergeReady`, whether the host would merge the change request now for the signed-in user, and `mergeMethods`, the methods the repository accepts with that user's usual one first. -`diff(ref, headSha, signal)` reads the change request's unified patch at head commit `headSha` as the host computes it, with `a/` and `b/` path prefixes, and `fileText(ref, commit, path, signal)` reads one file of its repository at a commit. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to the text or to a `ForgeFailure`, which is `too-large` when the host or the provider refuses an answer over its size limit. A provider returns a patch only when it describes `headSha`; once the head is another commit, `diff` resolves to `stale`. The first stale read at a head commit polls a watched change request at once so its next status names the current head; later stale reads at that commit wait for the regular poll, which bounds reads while the host's answers briefly disagree. Snapshots carry `unresolvedThreads` with `threadsTruncated`, which marks the count as a lower bound when the provider counted only some review threads. `fileUrl(ref, commit, path)` returns the web page of that file at that commit. +`diff(ref, headSha, signal)` reads the change request's unified patch at head commit `headSha` as the host computes it, with `a/` and `b/` path prefixes, and `fileText(ref, commit, path, signal)` reads one file of its repository at a commit. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to the text or to a `ForgeFailure`, which is `too-large` when the host or the provider refuses an answer over its size limit. A provider returns a patch only when it describes `headSha`; once the head is another commit, `diff` resolves to `stale`. The first stale read at a head commit polls a watched change request at once so its next status names the current head; later stale reads at that commit wait for the regular poll, which bounds reads while the host's answers briefly disagree. When the latest status of a watched change request names `headSha` and carries `baseSha`, the base commit the host recorded with that head, `diff` passes it to the provider, which may start reading from it before it confirms the head. Snapshots carry `unresolvedThreads` with `threadsTruncated`, which marks the count as a lower bound when the provider counted only some review threads. `fileUrl(ref, commit, path)` returns the web page of that file at that commit. Providers may implement `commits` and `compare` together. `commits(ref, signal)` lists a change request's commits oldest first, each with its full object name, first parent, title, author, and author time, at one head commit and with a `truncated` flag; `compare(ref, base, head, signal)` reads the unified patch from commit `base` to commit `head`. The registry returns undefined from `commits` and an `unavailable` failure from `compare` when the provider lacks either method, so a consumer can offer commit views only where both exist. diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md index 168012126c..e931eec159 100644 --- a/packages/experimental/forge/README.zh.md +++ b/packages/experimental/forge/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" `markReady(ref, signal)` 把草稿标记为可评审;`merge(ref, { method, headSha }, signal)` 只在 head 仍是 `headSha` 时合并变更请求。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错;主机接受时 resolve 为 undefined,否则为 `ForgeActionFailure`:`unauthenticated`、head 已越过 `headSha` 时的 `stale`,或带主机原因的 `failed`。两者结束后,被监听的变更请求会立即重新轮询。快照带有 `mergeReady`(主机现在是否会为已登录用户合并该变更请求)与 `mergeMethods`(仓库接受的合并方式,该用户常用的方式排在最前)。 -`diff(ref, headSha, signal)` 读取托管平台计算出的变更请求在 head 提交 `headSha` 处的统一补丁,路径带 `a/` 与 `b/` 前缀;`fileText(ref, commit, path, signal)` 读取其仓库中某个提交下的一个文件。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错,resolve 为文本或 `ForgeFailure`;托管平台或提供方因结果超过其大小上限而拒绝时,失败为 `too-large`。提供方只在补丁描述 `headSha` 时返回补丁;head 已是其他提交时,`diff` resolve 为 `stale`。同一 head 提交的第一次 stale 读取会让被 watch 的变更请求立即重新轮询,使下一次状态给出当前 head;之后在该提交上的 stale 读取等待常规轮询,从而在托管平台的应答短暂不一致时限制读取次数。快照携带 `unresolvedThreads` 与 `threadsTruncated`;提供方只统计了部分评审讨论时,后者表示该计数只是下限。`fileUrl(ref, commit, path)` 返回该文件在该提交下的网页地址。 +`diff(ref, headSha, signal)` 读取托管平台计算出的变更请求在 head 提交 `headSha` 处的统一补丁,路径带 `a/` 与 `b/` 前缀;`fileText(ref, commit, path, signal)` 读取其仓库中某个提交下的一个文件。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错,resolve 为文本或 `ForgeFailure`;托管平台或提供方因结果超过其大小上限而拒绝时,失败为 `too-large`。提供方只在补丁描述 `headSha` 时返回补丁;head 已是其他提交时,`diff` resolve 为 `stale`。同一 head 提交的第一次 stale 读取会让被 watch 的变更请求立即重新轮询,使下一次状态给出当前 head;之后在该提交上的 stale 读取等待常规轮询,从而在托管平台的应答短暂不一致时限制读取次数。被 watch 的变更请求的最新状态给出 `headSha` 并携带 `baseSha`(托管平台随该 head 记录的基准提交)时,`diff` 把它传给提供方,提供方可以在确认 head 之前就从它开始读取。快照携带 `unresolvedThreads` 与 `threadsTruncated`;提供方只统计了部分评审讨论时,后者表示该计数只是下限。`fileUrl(ref, commit, path)` 返回该文件在该提交下的网页地址。 提供方可以同时实现 `commits` 与 `compare`。`commits(ref, signal)` 在一个 head 提交上按从旧到新列出变更请求的提交,每个提交带完整对象名、第一个父提交、标题、作者与作者时间,并带 `truncated` 标志;`compare(ref, base, head, signal)` 读取从提交 `base` 到提交 `head` 的统一补丁。提供方缺少其中任一方法时,注册表的 `commits` 返回 undefined,`compare` 返回 `unavailable` 失败,因此使用方只在两者都存在时提供按提交查看。 diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index f52f77e818..4040c1f836 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -242,7 +242,8 @@ export class ForgeService extends Service { * computes them. The first `stale` failure at a head commit polls a watched * change request again at once, so its next status names the current head; * later stale reads at the same commit wait for the regular poll, which - * bounds the reads while the host's answers disagree. + * bounds the reads while the host's answers disagree. When the latest status of a watched change request names + * `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head. * @param ref - change request served by a registered provider. * @param headSha - head commit the patch must describe, usually the head of the latest status. * @param signal - cancellation of the read. @@ -250,8 +251,10 @@ export class ForgeService extends Service { * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. */ async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { + const latest = this.watches.get(changeRequestKey(ref))?.latest + const base = latest !== undefined && 'snapshot' in latest && latest.snapshot.headSha === headSha ? latest.snapshot.baseSha : undefined const result = await this.invoke(this.registration(ref.repository.forge), signal, - (provider, operation) => provider.diff(ref, headSha, operation)) + (provider, operation) => provider.diff(ref, headSha, operation, base)) const watch = this.watches.get(changeRequestKey(ref)) if (typeof result !== 'string' && result.code === 'stale' && watch !== undefined && watch.staleHead !== headSha) { watch.staleHead = headSha diff --git a/packages/experimental/forge/src/types.ts b/packages/experimental/forge/src/types.ts index 18c8b02e6d..b691744ec1 100644 --- a/packages/experimental/forge/src/types.ts +++ b/packages/experimental/forge/src/types.ts @@ -79,6 +79,11 @@ export interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** + * Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff; + * omitted when the provider does not report it. + */ + readonly baseSha?: string /** Lines the change request adds. */ readonly additions: number /** Lines the change request deletes. */ @@ -244,9 +249,10 @@ export interface ForgeProvider { /** * Read the changes of a change request at head commit `headSha` as the host computes them, as one unified * patch with `a/` and `b/` path prefixes. A patch is returned only when it describes `headSha`; once the - * head is another commit, the read resolves to a `stale` failure. + * head is another commit, the read resolves to a `stale` failure. `base`, when given, is the `baseSha` the + * latest status recorded with `headSha`; a provider may start reading from it before it confirms the head. */ - diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise /** Read the text of `path` at `commit` in the change request's repository. */ fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise /** Build the web page of `path` at `commit` in the change request's repository. */ diff --git a/packages/experimental/forge/tests/watch.spec.ts b/packages/experimental/forge/tests/watch.spec.ts index 4537d82670..0988bb3ad1 100644 --- a/packages/experimental/forge/tests/watch.spec.ts +++ b/packages/experimental/forge/tests/watch.spec.ts @@ -223,6 +223,22 @@ describe('forge watch', () => { expect(provider.fetches).toHaveLength(3) }) + it('passes the base commit of the latest status to a diff read at its head only', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1), { baseSha: 'e'.repeat(40) })) + const context = await setup(provider) + collect(context) + await vi.advanceTimersByTimeAsync(0) + const diff = vi.spyOn(provider, 'diff') + const signal = new AbortController().signal + + await context.forge.diff(ref(1), 'a'.repeat(40), signal) + await context.forge.diff(ref(1), 'f'.repeat(40), signal) + await context.forge.diff(ref(2), 'a'.repeat(40), signal) + + expect(diff.mock.calls.map(call => call[3])).toEqual(['e'.repeat(40), undefined, undefined]) + }) + it('polls a watched change request at once after a diff read finds a newer head, and only then', async () => { const provider = new FakeProvider() provider.results.set(key, snapshot(ref(1))) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index de37043f87..690a1a7476 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1271,7 +1271,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise', - description: 'Read the changes of a change request at one head commit as its host computes them. The first `stale` failure at a head commit polls a watched change request again at once, so its next status names the current head; later stale reads at the same commit wait for the regular poll, which bounds the reads while the host\'s answers disagree.', + description: 'Read the changes of a change request at one head commit as its host computes them. The first `stale` failure at a head commit polls a watched change request again at once, so its next status names the current head; later stale reads at the same commit wait for the regular poll, which bounds the reads while the host\'s answers disagree. When the latest status of a watched change request names `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head.', parameters: [{ name: 'ref', description: 'change request served by a registered provider.' }, { name: 'headSha', description: 'head commit the patch must describe, usually the head of the latest status.' }, { name: 'signal', description: 'cancellation of the read.' }], returns: 'one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it at `headSha`.', throws: ['when no provider serves the change request\'s forge, or with `signal.reason` when it aborts.'], @@ -6027,7 +6027,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ChangeRequestSnapshot', - declaration: 'export interface ChangeRequestSnapshot {\n readonly ref: ChangeRequestRef;\n readonly url: string;\n readonly title: string;\n readonly state: ChangeRequestState;\n readonly draft: boolean;\n readonly author: string | null;\n readonly headBranch: string;\n readonly headSha: string;\n readonly baseBranch: string;\n readonly additions: number;\n readonly deletions: number;\n readonly mergeability: Mergeability;\n readonly mergeReady: boolean;\n readonly mergeMethods: readonly MergeMethod[];\n readonly reviewDecision: ReviewDecision;\n readonly checks: readonly ChangeRequestCheck[];\n readonly checksRollup: ChecksRollup;\n readonly checksTruncated: boolean;\n readonly unresolvedThreads: number;\n readonly threadsTruncated: boolean;\n readonly updatedAt: string;\n readonly closedAt: string | null;\n}', + declaration: 'export interface ChangeRequestSnapshot {\n readonly ref: ChangeRequestRef;\n readonly url: string;\n readonly title: string;\n readonly state: ChangeRequestState;\n readonly draft: boolean;\n readonly author: string | null;\n readonly headBranch: string;\n readonly headSha: string;\n readonly baseBranch: string;\n readonly baseSha?: string;\n readonly additions: number;\n readonly deletions: number;\n readonly mergeability: Mergeability;\n readonly mergeReady: boolean;\n readonly mergeMethods: readonly MergeMethod[];\n readonly reviewDecision: ReviewDecision;\n readonly checks: readonly ChangeRequestCheck[];\n readonly checksRollup: ChecksRollup;\n readonly checksTruncated: boolean;\n readonly unresolvedThreads: number;\n readonly threadsTruncated: boolean;\n readonly updatedAt: string;\n readonly closedAt: string | null;\n}', }, { name: 'ChangeRequestState', @@ -6731,7 +6731,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ForgeProvider', - declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise;\n readiness(host: string, signal: AbortSignal): Promise;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n compare?(ref: ChangeRequestRef, base: string, head: string, s /* …truncated — full shape in source */', + declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise;\n readiness(host: string, signal: AbortSignal): Promise;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise;\n compare?(ref: ChangeRequestRef, base: string, /* …truncated — full shape in source */', }, { name: 'ForgeReadiness', From 1754e2ea9843460fbb1546007567166a9cc42009 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:22:26 +0800 Subject: [PATCH 44/81] perf(forge-panel): draw only what changes and what the reader reaches - Read a change request's patch and .gitattributes together. - Publish a panel state only when it differs from the published one. - File sections receive stable values and redraw only when theirs change; tab visibility reaches only the menus that need it. - An expanded file draws its lines once its section comes within reach of the scrollport, and highlights them in idle time in bounded fragments, again only when its segments or its language's grammar change. - A jumped-to file stays aligned while the sections around it draw. --- apps/web/tests/forge-panel.e2e.ts | 4 +- .../client-ui-forge/README.i18n.yaml | 8 +- .../experimental/client-ui-forge/README.md | 8 +- .../experimental/client-ui-forge/README.zh.md | 8 +- .../src/client/ChangesTab.module.css | 6 + .../client-ui-forge/src/client/ChangesTab.tsx | 134 +++++++--- .../client-ui-forge/src/client/DiffFile.tsx | 229 +++++++++++++----- .../src/client/section-reach.ts | 86 +++++++ .../experimental/client-ui-forge/src/index.ts | 41 +++- .../experimental/client-ui-forge/src/panel.ts | 3 + .../tests/changes-tab.client.spec.tsx | 128 +++++++++- .../tests/controller.host.spec.ts | 64 ++++- .../tests/diff-review.client.spec.tsx | 7 +- .../client-ui-forge/tests/harness.ts | 4 +- .../tests/section-reach.client.spec.ts | 112 +++++++++ 15 files changed, 718 insertions(+), 124 deletions(-) create mode 100644 packages/experimental/client-ui-forge/src/client/section-reach.ts create mode 100644 packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index b732d7dea5..c7020636d8 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -141,7 +141,7 @@ if (args[0] === 'api' && args[1] === 'graphql') { const pulls = { 7: { id: 'pull-7', number: 7, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false, - updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), + updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), baseRefOid: BASE, baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED', reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ isResolved: false }] }, commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-7', state: 'FAILURE', contexts: { @@ -152,7 +152,7 @@ if (args[0] === 'api' && args[1] === 'graphql') { 9: { id: 'pull-9', number: 9, url: 'https://github.com/octo/demo/pull/9', title: 'Add the merge button', state: state.mergedAt === null ? 'OPEN' : 'MERGED', isDraft: !state.ready, updatedAt: '2026-10-09T00:00:00Z', closedAt: state.mergedAt, author: { login: 'octo' }, - headRefName: 'feat/merge', headRefOid: heads()[9], baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE', + headRefName: 'feat/merge', headRefOid: heads()[9], baseRefName: 'main', baseRefOid: BASE, additions: 30, deletions: 2, mergeable: 'MERGEABLE', mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-9', state: 'SUCCESS', contexts: { diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 5b25741184..a096484521 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: 05ee6d9ba0b8d2c7 zh: 98c988e8f957511f /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 7495fe1e0d0ee643 - zh: f820a5790ae6e2a7 + en: 0afedb76112dfcb8 + zh: 52d5ddc7a4f9d31c /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b @@ -30,8 +30,8 @@ en: 2bc25a99e17789c4 zh: 4bec723fbf249d28 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: 5e29ac36aad15d02 - zh: e46339bb637a69ef + en: 8f8ed6efb2fb7872 + zh: 489730ecefad078b /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 27ff8a01c3..62f8212ad2 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -58,7 +58,7 @@ All files opens a searchable directory tree with the selected file scrolled to i
Implementation internals — click to expand -`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` for every change. Each change request keeps the latest snapshot a poll returned while later polls fail. A failed parallel Git read cancels and joins its siblings. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later. +`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` only when the state differs from the published one, so a reinspection that finds the same checkout reaches no follower. Each change request keeps the latest snapshot a poll returned while later polls fail. A failed parallel Git read cancels and joins its siblings. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later. Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute --git-common-dir`, `rev-parse --path-format=absolute --git-dir`, `symbolic-ref --short HEAD`, `rev-parse --short HEAD`, `rev-parse --abbrev-ref @{upstream}`, and `remote -v`; `branches` runs `symbolic-ref` and `for-each-ref --count=`; `diffBases` finds the default fork point as `localDiff` does below, runs `for-each-ref --sort=-committerdate` over `refs/heads/` and `refs/remotes/` within `maxDiffBytes`, leaves out symbolic refs such as `origin/HEAD`, and keeps at most `maxBranches` branches of each kind whose names contain the query. Inspection then sizes the `branch` scope for the local changes card: it finds the fork point, runs `diff --numstat -z -M` against it with the same helper and submodule restrictions as `localDiff` below, and lists untracked files. Every command uses the Session's resolved sandbox mode: `read-only` and `workspace-write` require confinement, while `danger-full-access` runs without a sandbox backend. Commands disable optional index writes, hooks, the filesystem monitor, prompts, and lazy object fetches, remove inherited Git selectors and secret-bearing variables, and use their own `gitTimeoutMs` deadline and `maxOutputBytes` limit per stream. The top-level, Git, and common Git directories Git prints are resolved through `ctx.fs` into the canonical process paths that the working-directory service records; a checkout whose Git directory differs from the common one is a linked worktree. The worktree option is usable under the `danger-full-access` sandbox mode, never under `read-only`, and under `workspace-write` only when both directories lie inside the canonical workspace root of the Session's sandbox policy. @@ -69,11 +69,11 @@ Restoration records `restoring` before mutation and `restored` only after succes Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. -`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. +`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, a patch together with its `.gitattributes`, where the first failure cancels the other read and waits for it to end, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. -The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. +The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and passes each file section values that keep their identity across its renders, so a render redraws only the sections whose values changed. An expanded file draws its lines once its section first comes within 1200px of the scrollport, keeps them drawn after that, and until then holds a 22px row per drawn line; the lines are highlighted with the shared code highlighter when the page is next idle, again only when the file's segments change or its language's grammar finishes loading; a chip title shows its name with the changes icon. | Source | Responsibility | |---|---| @@ -92,6 +92,7 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t | [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | Changes view | | [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | Base branch menu of the Branch scope | | [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | One file of the changes view | +| [`src/client/section-reach.ts`](src/client/section-reach.ts) | When file sections draw their lines, and idle-time highlighting | | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | Lens, test file switch, and viewed progress | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | Commit picker | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | Viewed marks kept in the browser | @@ -139,6 +140,7 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Changes scopes** — deferred: a scope for the last turn. The Branch scope's Default needs a remote whose default branch HEAD shares history with, or a worktree the Session created; otherwise a base branch must be chosen. - **Very many branches** — the base branch menu reads every ref before it filters, so a search can reach any branch. Refs over `maxDiffBytes`, about 165,000 at the default, or a listing over `gitTimeoutMs`, leave the menu saying that branches could not be read; Default keeps working. - **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. +- **Find in page over long diffs** — an expanded file draws its lines only once the reader scrolls near it, so the browser's find in page does not match lines of files further down until they are drawn; a page-wide search of the diff needs a find control of the view's own. - **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them. - **Review layers** — the built-in rules cover common conventions only, so a repository records others in `.gitattributes`; a path cannot show Rust's inline `#[cfg(test)]` modules or which declarations a language exports, and the Interface layer keeps whole files, so finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros. - **Chosen commits** — a change request lists at most `maxCommits` commits of the provider, the oldest 250 for GitHub, while the Branch scope lists its newest `maxCommits`; a run must follow first parents, so commits of a merged branch can be shown one at a time but not together with the branch's own; a commit range is read from the host on every view; comments on chosen commits can only be added to the input, as a GitHub review addresses the head. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 4aca1d2c38..bc4c12efd9 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -58,7 +58,7 @@ diff 按文件树的深度优先顺序排列:同层目录在前、文件在后
实现细节——点击展开 -`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。 +`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,只在状态与已发布的不同时发布新的 `ForgePanelState`,所以检查结果与原来相同时不会通知任何关注方。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。 检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`rev-parse --path-format=absolute --git-dir`、`symbolic-ref --short HEAD`、`rev-parse --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=`;`diffBases` 像下文的 `localDiff` 一样找出默认分叉点,在 `maxDiffBytes` 以内对 `refs/heads/` 与 `refs/remotes/` 运行 `for-each-ref --sort=-committerdate`,排除 `origin/HEAD` 等符号引用,每类最多保留 `maxBranches` 个名称包含查询文字的分支。之后,检查会为本地变更卡片统计 `branch` 范围的规模:找出分叉点,对它运行 `diff --numstat -z -M`,采用与下文 `localDiff` 相同的辅助程序和子模块限制,并列出未跟踪文件。每个命令都使用 Session 解析出的沙箱模式:`read-only` 与 `workspace-write` 需要沙箱,`danger-full-access` 无需沙箱后端。命令禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录、Git 目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径;Git 目录与公共 Git 目录不同的检出是链接 worktree。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。 @@ -69,11 +69,11 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 -Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,补丁与它的 `.gitattributes` 同时读取,先失败的一方会取消另一方并等它结束,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 -浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。 +浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并且传给每个文件段的值在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内时才绘制各行,此后一直保留;在此之前按每个绘制行 22px 占位。各行在页面下次空闲时用共享的代码高亮器高亮,之后只在文件的分段变化或该语言的语法加载完成时重新高亮;标签页标题显示变更图标与视图名称。 | 源码 | 职责 | |---|---| @@ -92,6 +92,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi | [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | 变更视图 | | [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | “分支”范围的基准分支菜单 | | [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | 变更视图中的一个文件 | +| [`src/client/section-reach.ts`](src/client/section-reach.ts) | 文件段何时绘制各行,以及空闲时高亮 | | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | 分层透镜、测试文件开关与已审阅进度 | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | 提交选择器 | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | 保存在浏览器中的已审阅标记 | @@ -139,6 +140,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **变更范围**——待办:上一轮的范围。“分支”范围的“默认”需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree;否则必须选择基准分支。 - **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。 - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 +- **长 diff 中的页内查找**——展开的文件要等读者滚动到附近才绘制各行,所以浏览器的页内查找在更靠后的文件绘制之前匹配不到它们的行;要在整个 diff 中查找,需要视图自己的查找控件。 - **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。 - **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。 - **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为最早的 250 个),“分支”范围则列出最新的 `maxCommits` 个;范围必须沿第一父提交相连,因此已合并分支的提交只能逐个查看,不能与本分支的提交一起查看;每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index f664c4b2fa..a4ef2ab3e3 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -521,6 +521,12 @@ font: var(--dsw-font-markdown-code-block); } +/* An expanded file the reader has not scrolled near yet: one 22px row (`.line`) per drawn line, so the scroll + range and the positions of the files below stay close to their drawn values. */ +.unreached { + height: calc(var(--forge-rows) * 22px); +} + /* One intrinsic-width track paints every unwrapped unified row across the scrolled width. */ .code:not([data-wrap]):not([data-split]) { display: grid; diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index f57bb937bb..8902be11e2 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -5,6 +5,8 @@ * layout. The view reads the changes when it opens, on each navigation, on * refresh, and when the change request's head or the checkout's `branch` * scope summary changes; a read keeps the previous changes on screen until it settles. + * File sections receive values that keep their identity across renders, so a + * render of the view redraws only the sections whose own values changed. */ import { @@ -39,7 +41,8 @@ import type { ChangesLens, createChangesStore } from './changes-store.ts' import { CommitMenu } from './CommitMenu.tsx' import { HideTestsSwitch, LensControl, LensMenu, LENSES, ViewedProgress } from './ReviewControls.tsx' import { viewedStateOf, type ViewedFiles, type ViewedMarks } from './viewed-files.ts' -import { Counts, DiffFile, type DiffFileAction } from './DiffFile.tsx' +import { Counts, DiffFile, type DiffFileAction, type DiffFileHandlers } from './DiffFile.tsx' +import { SectionReach, SectionReachContext, ViewVisibleContext } from './section-reach.ts' import { FileTreePane, type FileTreePosition } from './FileTreePane.tsx' import { changedFileTree, filesInTreeOrder } from './file-tree.ts' import { ChangedFilesMenu, type ChangedFilesProps, type ChangedFilesState } from './ChangedFiles.tsx' @@ -145,6 +148,12 @@ const OPEN_LINES = 5000 const NO_GAPS: ReadonlySet = new Set() +/** How long, in milliseconds, a jumped-to file stays at the top while the sections around it draw. */ +const JUMP_PIN_MS = 1000 + +/** Input that scrolls the file list, which ends a jump's pin. */ +const READER_SCROLL = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const + const SCOPE_LABEL: Record = { branch: 'diff.scope.branch', uncommitted: 'diff.scope.uncommitted', @@ -564,7 +573,7 @@ export function ChangesTab({ const files = useMemo(() => allFiles.filter(file => shownBy(file, lens, hideTests)), [allFiles, lens, hideTests]) const lensCounts = useMemo(() => Object.fromEntries(LENSES.map(item => [item, allFiles.filter(file => shownBy(file, item, hideTests)).length])) as Record, [allFiles, hideTests]) - const shownSource = data === null ? null : reviewSourceOf(data) + const shownSource = useMemo(() => data === null ? null : reviewSourceOf(data), [data]) const viewedKey = shownSource === null ? '' : viewedSourceOf(shownSource) const marks = useSyncExternalStore(viewedFiles.subscribe, () => viewedFiles.marksOf(viewedKey)) // The centered loader marks a first read; the toolbar's glyph turns only while shown changes are read again. @@ -582,9 +591,44 @@ export function ChangesTab({ const expandedByDefault = (file: ForgeDiffFile): boolean => initial.has(file.path) && viewedStateOf(marks, file.path, file.digest) !== 'viewed' const isExpanded = (file: ForgeDiffFile): boolean => chosen[file.path] ?? expandedByDefault(file) const sections = useRef(new Map()) - const scrollport = useRef(null) + const scrollport = useRef(null) + const reach = useMemo(() => new SectionReach(), []) + const attachScrollport = useCallback((element: HTMLDivElement | null) => { + scrollport.current = element + reach.attach(element) + }, [reach]) // A short final file cannot align with the viewport top; retain its jump until the reader scrolls away. const jumpPosition = useRef<{ readonly data: ChangesData; readonly top: number } | null>(null) + const releasePin = useRef<(() => void) | null>(null) + /** + * Keep a jumped-to section at the top of the scrollport for {@link JUMP_PIN_MS}: the sections around it draw their + * lines after the jump, which moves it. Scrolling input from the reader, another jump, or unmounting ends the pin. + */ + const pinJump = (port: HTMLElement, section: HTMLElement, owner: ChangesData): void => { + releasePin.current?.() + const until = performance.now() + JUMP_PIN_MS + let frame = 0 + const release = (): void => { + cancelAnimationFrame(frame) + for (const type of READER_SCROLL) port.removeEventListener(type, release) + if (releasePin.current === release) releasePin.current = null + } + const keep = (now: number): void => { + if (now > until) { + release() + return + } + const offset = section.getBoundingClientRect().top - port.getBoundingClientRect().top + if (Math.abs(offset) > 1 && port.scrollTop < port.scrollHeight - port.clientHeight - 1) { + section.scrollIntoView({ block: 'start' }) + jumpPosition.current = { data: owner, top: port.scrollTop } + } + frame = requestAnimationFrame(keep) + } + for (const type of READER_SCROLL) port.addEventListener(type, release, { passive: true }) + releasePin.current = release + frame = requestAnimationFrame(keep) + } useLayoutEffect(() => { if (!tab.visible || wanted === null || data === null) return // A file the lens or the test switch hides is shown before the view scrolls to it. @@ -594,10 +638,13 @@ export function ChangesTab({ if (hideTests && hidden.layer === 'test') actions.toggledTests(tab.id) return } - sections.current.get(wanted)?.scrollIntoView({ block: 'start' }) + const section = sections.current.get(wanted) + section?.scrollIntoView({ block: 'start' }) if (scrollport.current !== null) jumpPosition.current = { data, top: scrollport.current.scrollTop } + if (section !== undefined && scrollport.current !== null) pinJump(scrollport.current, section, data) setWanted(null) }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id]) + useEffect(() => () => { releasePin.current?.() }, []) const refresh = (): void => { setAttempt(value => value + 1) } const root = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.root : null // A chosen base branch that is gone or shares no history with HEAD offers the automatic fork point instead. @@ -749,6 +796,54 @@ export function ChangesTab({ markViewed(data, file, viewedStateOf(marks, file.path, file.digest) !== 'viewed') } + // What the section handlers and comment actions read when they run; replaced on every render. + const live = useRef({ data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t }) + live.current = { data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t } + const handlers = useMemo((): DiffFileHandlers => ({ + toggle: (file) => { + const byDefault = live.current.expandedByDefault(file) + setChosen(value => ({ ...value, [file.path]: !(value[file.path] ?? byDefault) })) + }, + reveal: (path) => { setRevealed(value => new Set([...value, path])) }, + expand: (file, index) => { + const { data: owner, expand: open } = live.current + /* v8 ignore next -- sections render only while changes are loaded */ + if (owner !== null) open(owner, file, index) + }, + view: (file, viewed) => { + const { data: owner, markViewed: mark } = live.current + /* v8 ignore next -- sections render only while changes are loaded */ + if (owner !== null) mark(owner, file, viewed) + }, + actions: (file) => { + const { data: owner, fileActions: list } = live.current + /* v8 ignore next -- sections render only while changes are loaded */ + return owner === null ? [] : list(owner, file) + }, + section: (path, element) => { + if (element === null) sections.current.delete(path) + else sections.current.set(path, element) + }, + }), []) + const reviewStarted = reviewState.comments.length > 0 + const publishingBlocked = reviewState.uncertain !== null + // Comments on a commit range or on local changes go to the input only; a whole change request also takes GitHub comments. + const publishable = data?.kind === 'change' && data.commits === null + const fileReview = useMemo((): ReviewActions | undefined => shownSource === null ? undefined : { + source: shownSource, + addToInput: addReviewToInput, + records: queue, openLink, reviewStarted, + onQuoted: (selection) => { if (!queue.recordAgent(selection)) live.current.report('review.saveFailed') }, + publishingBlocked, + ...publishable ? { + queue: selection => queue.add(selection), + publish: selection => selection.source.kind === 'change' + ? live.current.publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment }, + url => queue.published(selection, url)) + : Promise.resolve(live.current.t('review.stale')), + } : {}, + }, [shownSource, addReviewToInput, queue, openLink, reviewStarted, publishingBlocked, publishable]) + let body: ReactNode if (data === null) { if (state === null && snapshot?.disconnected) { @@ -782,7 +877,7 @@ export function ChangesTab({ body =
{orphanRecords}
{t('lens.empty')}
} else { body = ( -
{ if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null @@ -792,40 +887,19 @@ export function ChangesTab({ return ( 0, - onQuoted: (selection) => { if (!queue.recordAgent(selection)) report('review.saveFailed') }, - publishingBlocked: reviewState.uncertain !== null, - ...data.kind === 'local' || data.commits !== null ? {} : { - queue: selection => queue.add(selection), - publish: selection => selection.source.kind === 'change' - ? publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment }, - url => queue.published(selection, url)) - : Promise.resolve(t('review.stale')), - }, - }} + review={fileReview} expanded={isExpanded(file)} - onToggle={() => { setChosen(value => ({ ...value, [path]: !(value[path] ?? expandedByDefault(file)) })) }} large={drawnLines(file) > LARGE_FILE_LINES} revealed={revealed.has(path)} - onReveal={() => { setRevealed(value => new Set([...value, path])) }} text={current.texts[path]} open={current.open[path] ?? NO_GAPS} - onExpand={(index) => { expand(data, file, index) }} split={split} wrap={wrap} badge={badgeOf(file, data, t)} tooltip={pathTooltipOf(file, t)} - actions={fileActions(data, file)} viewed={viewedStateOf(marks, path, file.digest)} - onViewed={(viewed) => { markViewed(data, file, viewed) }} - sectionRef={(element) => { - if (element === null) sections.current.delete(path) - else sections.current.set(path, element) - }} + handlers={handlers} t={t} /> ) @@ -940,7 +1014,9 @@ export function ChangesTab({ {data?.kind === 'local' && data.omitted > 0 && {t('diff.omitted', { count: data.omitted })}} {reviewForm}
- {body} + + {body} + {allFiles.length > 0 && }
diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index ad46c66132..bd0b9ac500 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -1,10 +1,12 @@ /** * One file of the changes view: a sticky header with the path, badges, line * counts, and file actions, then its hunks unified or side by side with the - * unchanged lines between them collapsed into rows that expand them. + * unchanged lines between them collapsed into rows that expand them. A section + * draws its lines once it comes within reach of the scrollport and highlights + * them when the page is next idle; it redraws only when its own props change. */ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' import clsx from 'clsx' import { flushSync } from 'react-dom' import { @@ -14,7 +16,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ForgeDiffFile } from '../types.ts' -import { segmentsOf, splitRows, type DiffRow, type SplitCell, type SplitRow } from './diff-rows.ts' +import { drawnLines, segmentsOf, splitRows, type DiffRow, type DiffSegment, type SplitCell, type SplitRow } from './diff-rows.ts' import { codeSideAt, readCodeSelection } from './code-selection.ts' import { reviewMatches, selectReviewLines, type ReviewSelection } from './review.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' @@ -24,6 +26,7 @@ import reviewCss from './ReviewComposer.module.css' import { FileLabel } from './FileLabel.tsx' import type { ForgePanelKey, NS } from './locales.ts' import type { ViewedState } from './viewed-files.ts' +import { SectionReachContext, ViewVisibleContext, whenIdle } from './section-reach.ts' import css from './ChangesTab.module.css' type Translate = PropsLocale['t'] @@ -39,30 +42,41 @@ export interface DiffFileAction { /** New side of a file: its lines once read, `loading` while a read runs, or undefined before any. */ export type DiffFileText = readonly string[] | 'loading' | undefined +/** + * What every file section of one changes view calls. The view passes the same + * object on every render, so a section redraws only when its own values change. + */ +export interface DiffFileHandlers { + /** Expand or collapse a file. */ + readonly toggle: (file: ForgeDiffFile) => void + /** Show a large file anyway. */ + readonly reveal: (path: string) => void + /** Expand one gap of a file. */ + readonly expand: (file: ForgeDiffFile, index: number) => void + /** Mark a file viewed, which also collapses it, or clear the mark. */ + readonly view: (file: ForgeDiffFile, viewed: boolean) => void + /** Rows of a file's menu, read whenever the menu draws. */ + readonly actions: (file: ForgeDiffFile) => readonly DiffFileAction[] + /** Receive a file's section element, which scrolling to the file targets, or null once it unmounts. */ + readonly section: (path: string, element: HTMLElement | null) => void +} + /** Props of one file section. */ export interface DiffFileProps { - /** Whether the owning tab is visible and may show temporary menus. */ - readonly visible: boolean /** Comment destinations and the source being reviewed. */ - readonly review?: ReviewActions + readonly review?: ReviewActions | undefined /** The file and its hunks. */ readonly file: ForgeDiffFile /** Whether the section shows its lines. */ readonly expanded: boolean - /** Expand or collapse the section. */ - readonly onToggle: () => void /** Whether the file shows the large-file notice instead of its lines until revealed. */ readonly large: boolean /** Whether the user revealed a large file. */ readonly revealed: boolean - /** Show a large file anyway. */ - readonly onReveal: () => void /** The file's new side, which supplies collapsed unchanged lines. */ readonly text: DiffFileText /** Indexes of the expanded gaps. */ readonly open: ReadonlySet - /** Expand one gap. */ - readonly onExpand: (index: number) => void /** Whether deletions and additions show in two columns; a file with only additions or only deletions keeps one. */ readonly split: boolean /** Whether long lines wrap. */ @@ -71,14 +85,10 @@ export interface DiffFileProps { readonly badge: string /** Hover text of the path: both paths of a renamed file, else the path. */ readonly tooltip: string - /** Rows of the file's menu. */ - readonly actions: readonly DiffFileAction[] /** Whether the reader marked this change viewed, or marked an earlier change of the file. */ readonly viewed: ViewedState - /** Mark the file viewed, which also collapses it, or clear the mark. */ - readonly onViewed: (viewed: boolean) => void - /** Receives the section element, which scrolling to the file targets. */ - readonly sectionRef: (element: HTMLElement | null) => void + /** Actions shared by every section of the view. */ + readonly handlers: DiffFileHandlers /** Forge panel copy. */ readonly t: Translate } @@ -103,26 +113,87 @@ export function Counts({ file, t }: { const SIGN: Record = { add: '+', del: '-', context: '' } -/** Highlighted tokens of one side's lines by line number, or undefined while the grammar is unavailable. */ -type SideSpans = ReadonlyMap | undefined +/** Highlighted tokens of one side's lines by line number. */ +type SideSpans = Map -/** A segment ready to draw: collapsed lines, or rows with the tokens of each side. */ -type Prepared = - | { readonly kind: 'gap'; readonly index: number; readonly count: number | null } - | { readonly kind: 'rows'; readonly rows: readonly DiffRow[]; readonly old: SideSpans; readonly new: SideSpans } +/** Highlighted tokens of a file's drawn lines on each side; a line without an entry draws as plain text. */ +interface FileSpans { + readonly old: SideSpans + readonly new: SideSpans +} -/** Highlight one side of a run of rows as one fragment, so tokens spanning lines keep their state. */ -function sideSpans(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): SideSpans { - const numbered: { readonly no: number; readonly text: string }[] = [] - for (const row of rows) { - const no = row[side] - if (no !== null) numbered.push({ no, text: row.text }) +/** Lines highlighted as one fragment; a longer run splits here, where a token spanning the split restarts. */ +const HIGHLIGHT_CHUNK_LINES = 100 + +/** Numbered lines of one side that are highlighted together. */ +interface HighlightChunk { + readonly side: 'old' | 'new' + readonly lines: readonly { readonly no: number; readonly text: string }[] +} + +/** Split each run of rows of each side into fragments of at most {@link HIGHLIGHT_CHUNK_LINES} lines. */ +function highlightChunks(segments: readonly DiffSegment[]): HighlightChunk[] { + const chunks: HighlightChunk[] = [] + for (const segment of segments) { + if (segment.kind === 'gap') continue + for (const side of ['old', 'new'] as const) { + const lines: { readonly no: number; readonly text: string }[] = [] + for (const row of segment.rows) { + const no = row[side] + if (no !== null) lines.push({ no, text: row.text }) + } + for (let at = 0; at < lines.length; at += HIGHLIGHT_CHUNK_LINES) { + chunks.push({ side, lines: lines.slice(at, at + HIGHLIGHT_CHUNK_LINES) }) + } + } } - if (numbered.length === 0) return new Map() - const highlighted = highlighter(numbered.map(line => line.text).join('\n')) - if (highlighted === undefined) return undefined + return chunks +} + +/** Highlight one fragment, so tokens spanning its lines keep their state, into `spans`. */ +function addChunkSpans(spans: FileSpans, chunk: HighlightChunk, highlighter: CodeHighlighter): void { + const highlighted = highlighter(chunk.lines.map(line => line.text).join('\n')) + /* v8 ignore next -- a grammar that reported ready stays registered, so its fragments always tokenize */ + if (highlighted === undefined) return // Shiki omits a final empty token row, so a final empty line gets no tokens. - return new Map(numbered.map((line, index) => [line.no, highlighted[index] ?? []])) + for (const [index, line] of chunk.lines.entries()) spans[chunk.side].set(line.no, highlighted[index] ?? []) +} + +/** + * Highlight a file's drawn lines in idle time, once its language's grammar is ready: each idle period highlights + * fragments while it lasts, at least one, and the lines take their tokens when the last fragment is done. Until + * then, the lines keep the tokens of the previous pass, by line number, and new lines draw as plain text. A grammar + * that loads for another language starts no pass. + */ +function useFileSpans(path: string, segments: readonly DiffSegment[]): FileSpans | undefined { + const highlighter = useCodeHighlighter(languageForPath(path)) + // The highlighter changes whenever any grammar loads; the empty fragment tells whether this language's is ready. + const ready = highlighter('') !== undefined + const current = useRef(highlighter) + current.current = highlighter + const [spans, setSpans] = useState(undefined) + useEffect(() => { + if (!ready) return + const chunks = highlightChunks(segments) + const pass: FileSpans = { old: new Map(), new: new Map() } + let next = 0 + const step = (deadline: IdleDeadline | undefined): void => { + for (;;) { + const chunk = chunks[next] + if (chunk === undefined) { + setSpans(pass) + return + } + addChunkSpans(pass, chunk, current.current) + next++ + if ((deadline?.timeRemaining() ?? 0) <= 0) break + } + cancel = whenIdle(step) + } + let cancel = whenIdle(step) + return () => { cancel() } + }, [segments, ready]) + return spans } function Code({ text, spans, old, next }: { @@ -143,16 +214,20 @@ function pairKind(pair: SplitRow): 'context' | 'change' { return pair.left !== null && pair.left.kind === 'context' ? 'context' : 'change' } +/** The composer of a new comment, shown with its menus only while the changes tab is visible. */ +function VisibleComposer(props: Omit[0], 'visible'>): ReactNode { + return +} + /** The hunks of one file with their collapsed and expanded unchanged lines. */ -function Lines({ file, text, open, onExpand, split, wrap, review, visible, comments, t }: Pick & { readonly comments: FileReviewState }): ReactNode { +function Lines({ file, text, open, handlers, split, wrap, review, comments, t }: Pick & { readonly comments: FileReviewState }): ReactNode { const lines = typeof text === 'object' ? text : undefined const columns = split && file.additions > 0 && file.deletions > 0 const singleSide = file.status === 'added' ? 'R' : file.status === 'deleted' ? 'L' : null let maximum = Math.max(1, lines?.length ?? 0, (lines?.length ?? 0) + file.deletions - file.additions) for (const hunk of file.hunks) maximum = Math.max(maximum, hunk.oldStart + hunk.oldLines - 1, hunk.newStart + hunk.newLines - 1) const style: CSSProperties & { '--forge-number-width': string } = { '--forge-number-width': `calc(${Math.max(2, String(maximum).length)}ch + 12px)` } - const highlighter = useCodeHighlighter(languageForPath(file.path)) const [selection, setSelection] = useState(null) const root = useRef(null) const origin = useRef<{ side: 'L' | 'R'; line: number } | null>(null) @@ -210,9 +285,8 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme window.removeEventListener('pointercancel', finish) } }, [selecting, nativeSelection]) - const prepared = useMemo(() => segmentsOf(file, lines, open).map((segment): Prepared => segment.kind === 'gap' - ? segment - : { ...segment, old: sideSpans(segment.rows, 'old', highlighter), new: sideSpans(segment.rows, 'new', highlighter) }), [file, lines, open, highlighter]) + const segments = useMemo(() => segmentsOf(file, lines, open), [file, lines, open]) + const spans = useFileSpans(file.path, segments) // Returns whether the range was accepted; a range across hunks or outside them keeps the previous selection. const choose = (side: 'L' | 'R', line: number, extend: boolean): boolean => { if (review === undefined || editing) return false @@ -286,15 +360,15 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme )} } - const cell = (value: SplitCell | null, spans: SideSpans, side: 'L' | 'R'): ReactNode => value === null + const cell = (value: SplitCell | null, side: 'L' | 'R'): ReactNode => value === null ? : - {number(side, value.no, true)} + {number(side, value.no, true)} const rows: ReactNode[] = [] let inserted = false - const editor = review === undefined || selection === null || !editing ? null : { const native = window.getSelection() if (root.current?.contains(native?.anchorNode ?? null) === true) native?.removeAllRanges() @@ -315,10 +389,10 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme } const endsAt = (old: number | null, next: number | null): boolean => selection !== null && (selection.comment.side === 'L' ? old : next) === selection.comment.endLine - for (const [at, segment] of prepared.entries()) { + for (const [at, segment] of segments.entries()) { if (segment.kind === 'gap') { rows.push() @@ -326,7 +400,7 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme for (const [index, pair] of splitRows(segment.rows).entries()) { rows.push(
{ extendAdd(event.buttons, pair.left?.no ?? null, pair.right?.no ?? null) }}> - {cell(pair.left, segment.old, 'L')}{cell(pair.right, segment.new, 'R')} + {cell(pair.left, 'L')}{cell(pair.right, 'R')}
) if (!inserted && endsAt(pair.left?.no ?? null, pair.right?.no ?? null)) { rows.push(editor) @@ -341,7 +415,7 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme rows.push(
{ extendAdd(event.buttons, row.old, row.new) }}> {singleSide !== 'R' && number('L', row.old, row.kind === 'del' && !labeled)}{singleSide !== 'L' && number('R', row.new, row.kind !== 'del' && !labeled)}{SIGN[row.kind]} - +
) if (!inserted && endsAt(row.old, row.new)) { rows.push(editor) @@ -374,8 +448,13 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme }}>{rows} } +/** Whether a file's body draws its hunks line by line rather than a note or a collapsed strip. */ +function drawsLines(file: ForgeDiffFile, large: boolean, revealed: boolean): boolean { + return !file.binary && !file.oversized && file.hunks.length > 0 && (!large || revealed) +} + function Body(props: DiffFileProps): ReactNode { - const { file, large, revealed, onReveal, review, t } = props + const { file, large, revealed, handlers, review, t } = props const comments = useFileReview(review, file.path) const records = review === undefined ? null : comments.threads.map(thread => ) @@ -385,7 +464,7 @@ function Body(props: DiffFileProps): ReactNode { if (large && !revealed) { // A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines. return ( - <>{records} @@ -394,12 +473,20 @@ function Body(props: DiffFileProps): ReactNode { return } -/** File actions live only while their owning tab is visible. */ -function FileMenu({ name, actions, t }: { +/** Props of a file's menu. */ +interface FileMenuProps { readonly name: string - readonly actions: readonly DiffFileAction[] + readonly file: ForgeDiffFile + readonly handlers: DiffFileHandlers readonly t: Translate -}): ReactNode { +} + +/** File actions live only while their owning tab is visible, so hiding the tab closes the menu. */ +function FileMenu(props: FileMenuProps): ReactNode { + return useContext(ViewVisibleContext) ? : null +} + +function OpenableFileMenu({ name, file, handlers, t }: FileMenuProps): ReactNode { const [menuOpen, setMenuOpen] = useState(false) return ( )} > - {actions.map(action => ( + {handlers.actions(file).map(action => ( { @@ -438,13 +525,28 @@ function FileMenu({ name, actions, t }: { } /** - * Render one file of a diff. - * @param props - the file, its section and gap state, layout choices, menu actions, and copy. + * Render one file of a diff. Until the section first comes within reach of the scrollport, an expanded file that + * draws lines shows a box of about their height instead. + * @param props - the file, its section and gap state, layout choices, shared actions, and copy. * @returns the file section. */ -export function DiffFile(props: DiffFileProps): ReactNode { - const { file, expanded, onToggle, badge, tooltip, actions, viewed, onViewed, sectionRef, t } = props +function DiffFileSection(props: DiffFileProps): ReactNode { + const { file, expanded, large, revealed, badge, tooltip, viewed, handlers, t } = props const name = file.path.slice(file.path.lastIndexOf('/') + 1) + const reach = useContext(SectionReachContext) + const element = useRef(null) + const [reached, setReached] = useState(false) + const waiting = expanded && !reached && reach !== null && drawsLines(file, large, revealed) + useEffect(() => { + /* v8 ignore next -- React attaches the section before it runs effects */ + if (!waiting || element.current === null) return + return reach.observe(element.current, () => { setReached(true) }) + }, [waiting, reach]) + const sectionRef = useCallback((node: HTMLElement | null) => { + element.current = node + handlers.section(file.path, node) + }, [handlers, file.path]) + const placeholder: CSSProperties & { '--forge-rows': number } = { '--forge-rows': drawnLines(file) } return (
@@ -454,7 +556,7 @@ export function DiffFile(props: DiffFileProps): ReactNode { className={css.fileToggle} aria-expanded={expanded} aria-label={t(expanded ? 'change.collapse' : 'change.expand', { name })} - onClick={onToggle} + onClick={() => { handlers.toggle(file) }} > @@ -468,12 +570,15 @@ export function DiffFile(props: DiffFileProps): ReactNode { {!file.binary && } - {props.visible && } + - {expanded && } + {expanded && (waiting ?
: )}
) } + +/** One file of a diff, redrawn only when its props change. */ +export const DiffFile = memo(DiffFileSection) diff --git a/packages/experimental/client-ui-forge/src/client/section-reach.ts b/packages/experimental/client-ui-forge/src/client/section-reach.ts new file mode 100644 index 0000000000..d926bd1ee2 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/section-reach.ts @@ -0,0 +1,86 @@ +/** + * Reach of the changes view's file sections: a section's lines mount once the + * section first comes within a margin of the files scrollport, and stay + * mounted, so long diffs draw only what the reader approaches. + */ + +import { createContext } from 'react' + +/** Distance beyond the scrollport's edges within which a section counts as reached. */ +const REACH_MARGIN = '1200px 0px' + +const noop = (): void => {} + +/** + * Watches the file sections of one changes view against its scrollport. Without + * `IntersectionObserver` every section is reached at once. + */ +export class SectionReach { + private observer: IntersectionObserver | undefined + private readonly waiting = new Map void>() + + /** + * Measure sections against `root`, or stop measuring when it unmounts. Sections that are still waiting move to the new root. + * @param root - the files scrollport, or null. + */ + attach(root: HTMLElement | null): void { + this.observer?.disconnect() + this.observer = undefined + if (root === null || typeof IntersectionObserver === 'undefined') return + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue + const reached = this.waiting.get(entry.target) + /* v8 ignore next -- the observer reports only sections still waiting, which it stops watching on release */ + if (reached === undefined) continue + this.waiting.delete(entry.target) + observer.unobserve(entry.target) + reached() + } + }, { root, rootMargin: REACH_MARGIN }) + this.observer = observer + for (const element of this.waiting.keys()) observer.observe(element) + } + + /** + * Call `reached` once when `element` comes within reach. + * @param element - a file section inside the scrollport. + * @param reached - runs once, from the observer's callback or at once without `IntersectionObserver`. + * @returns the disposer that stops watching before the section is reached. + */ + observe(element: Element, reached: () => void): () => void { + if (typeof IntersectionObserver === 'undefined') { + reached() + return noop + } + this.waiting.set(element, reached) + this.observer?.observe(element) + return () => { + this.waiting.delete(element) + this.observer?.unobserve(element) + } + } +} + +/** The reach of the enclosing changes view; null outside one, where every section counts as reached. */ +export const SectionReachContext = createContext(null) + +/** Whether the enclosing changes tab is visible and may show temporary menus; true outside one. */ +export const ViewVisibleContext = createContext(true) + +/** Longest wait, in milliseconds, before deferred highlighting runs although the page stays busy. */ +const IDLE_TIMEOUT_MS = 500 + +/** + * Run `task` when the page is next idle, or after other queued work where the browser lacks `requestIdleCallback`. + * @param task - work that may wait for a frame to finish; it receives the idle period, or undefined without one. + * @returns the canceller, which has no effect after `task` ran. + */ +export function whenIdle(task: (deadline: IdleDeadline | undefined) => void): () => void { + if (typeof requestIdleCallback === 'function') { + const id = requestIdleCallback(task, { timeout: IDLE_TIMEOUT_MS }) + return () => { cancelIdleCallback(id) } + } + const id = setTimeout(() => { task(undefined) }, 0) + return () => { clearTimeout(id) } +} diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index 708eb757f4..b6dc034eac 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -418,9 +418,7 @@ export class ForgePanelController extends TypertRemoteService { const version = JSON.stringify([headSha, baseBranch]) let read = this.patches.get(key, version) if (read === undefined) { - const patch = await this.home.forge.diff(ref, headSha, signal) - if (typeof patch !== 'string') throw readError(patch) - read = { patch, attributes: await this.attributesAt(ref, headSha, signal) } + read = await this.patchAt(ref, headSha, async operation => await this.home.forge.diff(ref, headSha, operation), signal) this.patches.set(key, version, read) } const files = this.layers.classify(parseDiff(read.patch), read.attributes) @@ -683,13 +681,44 @@ export class ForgePanelController extends TypertRemoteService { if (start === 'not-linear') throw new RemoteError('forgePanel/commits-not-linear', 'The chosen commits are not one run of first parents', {}) const parent = start.parent if (parent === null) throw new RemoteError('forgePanel/diff-failed', 'The first chosen commit has no parent to compare with', {}) - const patch = await this.home.forge.compare(ref, parent, commits.to, signal) - if (typeof patch !== 'string') throw readError(patch) - const files = this.layers.classify(parseDiff(patch), await this.attributesAt(ref, commits.to, signal)) + const { patch, attributes } = await this.patchAt( + ref, commits.to, async operation => await this.home.forge.compare(ref, parent, commits.to, operation), signal, + ) + const files = this.layers.classify(parseDiff(patch), attributes) const fileUrls = Object.fromEntries(files.map(file => [file.path, this.home.forge.fileUrl(ref, commits.to, file.path)])) return { headSha: commits.to, files, localAhead: null, fileUrls } } + /** + * Read a patch that ends at `commit` together with the root `.gitattributes` at that commit. The first failure + * cancels the other read and waits for it to end before it passes on. + * @param ref - change request whose repository holds the commit. + * @param commit - commit the patch ends at. + * @param readPatch - reads the patch under the given cancellation. + * @param signal - request cancellation. + * @returns the patch and the attributes text, empty when the commit has none. + * @throws the Remote error for a refused patch or attributes read, or `signal.reason` when it aborts. + */ + private async patchAt( + ref: ChangeRequestRef, commit: string, readPatch: (signal: AbortSignal) => Promise, signal: AbortSignal, + ): Promise<{ readonly patch: string; readonly attributes: string }> { + const sibling = new AbortController() + const operation = AbortSignal.any([signal, sibling.signal]) + const patch = readPatch(operation).then((result) => { + if (typeof result !== 'string') throw readError(result) + return result + }) + const attributes = this.attributesAt(ref, commit, operation) + try { + const [text, rules] = await Promise.all([patch, attributes]) + return { patch: text, attributes: rules } + } catch (error) { + sibling.abort() + await Promise.allSettled([patch, attributes]) + throw error + } + } + /** The root `.gitattributes` of a change request's repository at a commit, empty when the commit has none. */ private async attributesAt(ref: ChangeRequestRef, commit: string, signal: AbortSignal): Promise { const attributes = await this.home.forge.fileText(ref, commit, ATTRIBUTES_PATH, signal) diff --git a/packages/experimental/client-ui-forge/src/panel.ts b/packages/experimental/client-ui-forge/src/panel.ts index 0c21c934c4..ead8fecc97 100644 --- a/packages/experimental/client-ui-forge/src/panel.ts +++ b/packages/experimental/client-ui-forge/src/panel.ts @@ -1,5 +1,6 @@ /** Live panel state of one followed Session, shared by all of its followers. */ +import { isDeepStrictEqual } from 'node:util' import type { Context } from '@deepseek-ai/cordis' import type { ChangeRequestSnapshot, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge' import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session' @@ -230,6 +231,8 @@ export class SessionPanel { } private publish(next: ForgePanelState): void { + // A reinspection after a tool result usually finds the same checkout; equal states reach no follower. + if (isDeepStrictEqual(next, this.current)) return this.current = next for (const notify of [...this.followers]) notify() } diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index a4b277e34c..f1d3e62ef1 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** The changes tab: reading each source and scope, the toolbar, file sections, unchanged lines, notices, and failures. */ import { useSyncExternalStore } from 'react' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PaneId, SidebarRightTabActions, SidebarRightTabInfo, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -713,9 +713,10 @@ describe('ChangesTab base branch', () => { }) it('records review comments under the compared ref and names the base the Host reports', async () => { - const renderDiff = DiffFileModule.DiffFile + // The memoized section draws through its inner component, which the spy observes. + const renderDiff = DiffFileModule.DiffFile.type let source: ReviewActions['source'] | undefined - vi.spyOn(DiffFileModule, 'DiffFile').mockImplementation((props) => { + vi.spyOn(DiffFileModule.DiffFile, 'type').mockImplementation((props) => { source = props.review?.source return renderDiff(props) }) @@ -789,6 +790,72 @@ describe('ChangesTab base branch', () => { }) }) +describe('ChangesTab drawing work', () => { + it('draws the lines of an expanded file once its section comes within reach of the scrollport', async () => { + const watched = new Map() + let root: Element | Document | null | undefined + vi.stubGlobal('IntersectionObserver', class { + constructor(readonly report: IntersectionObserverCallback, options?: IntersectionObserverInit) { root = options?.root } + observe(element: Element): void { watched.set(element, this.report) } + unobserve(element: Element): void { watched.delete(element) } + disconnect(): void { watched.clear() } + }) + try { + const m = mount() + await settle() + const app = m.section('src/app.ts') + expect(root).toBe(m.view.container.querySelector('[data-forge-changes-scroll]')) + expect(app.querySelector('[data-forge-diff-unreached]')).not.toBeNull() + expect(rowTexts(app)).toEqual([]) + + act(() => { + // The double reports only the entries, which is all the view reads. + Reflect.apply(watched.get(app)!, undefined, [[{ target: app, isIntersecting: true }], undefined]) + }) + expect(app.querySelector('[data-forge-diff-unreached]')).toBeNull() + expect(rowTexts(app).length).toBeGreaterThan(0) + expect(watched.has(app)).toBe(false) + } finally { + vi.unstubAllGlobals() + } + }) + + it('highlights a file in idle periods, as many fragments as each period allows, then draws the tokens at once', async () => { + // Idle periods with this much time left, in turn; afterwards each period has none. + const remaining = [5] + const periods: number[] = [] + vi.stubGlobal('requestIdleCallback', (task: IdleRequestCallback) => setTimeout(() => { + const left = remaining.shift() ?? 0 + periods.push(left) + task({ didTimeout: false, timeRemaining: () => left }) + }, 0)) + vi.stubGlobal('cancelIdleCallback', (id: number) => { clearTimeout(id) }) + try { + const m = mount() + await settle() + const app = m.section('src/app.ts') + await waitFor(() => { expect(app.querySelectorAll('[data-diff-code]').length).toBeGreaterThan(0) }) + // The first period highlights both sides of the file's one run and still has time; the second finds nothing left. + expect(periods.slice(0, 2)).toEqual([5, 0]) + } finally { + // Unmounting cancels pending passes through the stubbed canceller. + cleanup() + vi.unstubAllGlobals() + } + }) + + it('leaves file sections undrawn while the reader filters the file list', async () => { + // The memoized section draws through its inner component, which the spy counts. + const draw = vi.spyOn(DiffFileModule.DiffFile, 'type') + const m = mount() + await settle() + const drawn = draw.mock.calls.length + fireEvent.change(m.view.getAllByRole('textbox', { name: 'Filter files…' })[0]!, { target: { value: 'app' } }) + await settle() + expect(draw.mock.calls.length).toBe(drawn) + }) +}) + describe('ChangesTab layout', () => { it('draws lines side by side, then in one column, and wraps them by choice', async () => { const m = mount() @@ -800,7 +867,8 @@ describe('ChangesTab layout', () => { expect([...app.querySelectorAll('[data-diff-pair]')].map(pair => pair.getAttribute('data-diff-pair'))).toEqual([ 'context', 'change', 'change', 'context', 'context', 'change', ]) - expect(app.querySelectorAll('[data-diff-code]').length).toBeGreaterThan(0) + // Highlighting waits for the page to be idle. + await waitFor(() => { expect(app.querySelectorAll('[data-diff-code]').length).toBeGreaterThan(0) }) expect(m.section('docs/gone.txt').querySelector('[data-diff-code]')).toBeNull() expect(rowTexts(m.section('docs/gone.txt'))).toEqual(['bye']) expect(rowTexts(m.section('src/new.ts'))).toEqual(['export const added = 1', '']) @@ -887,6 +955,53 @@ describe('ChangesTab layout', () => { expect(scrolled).toHaveBeenCalledWith({ block: 'start' }) }) + it('keeps a jumped-to file at the top while sections around it draw, until the reader scrolls or a second passes', async () => { + const m = mount() + await settle() + const { viewport } = fileViewport(m) + Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: 1200 }) + Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 300 }) + const target = m.section('src/new.ts') + // Sections above the target draw after the jump and push it down by this much until it scrolls again. + let shift = 0 + vi.spyOn(target, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, shift, 600, 200)) + scrolled.mockImplementation(() => { shift = 0 }) + vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame', 'performance'] }) + try { + const jump = (): void => { + fireEvent.click(m.view.getByRole('button', { name: 'All 6 files' })) + fireEvent.click(m.view.getByRole('menuitem', { name: 'src/new.ts' })) + } + jump() + expect(scrolled).toHaveBeenCalledOnce() + shift = 80 + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(2) + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(2) + + // At the bottom of the list nothing can move the file higher. + viewport.scrollTop = 900 + shift = 40 + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(2) + viewport.scrollTop = 0 + + fireEvent.wheel(viewport) + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(2) + + jump() + expect(scrolled).toHaveBeenCalledTimes(3) + act(() => { vi.advanceTimersByTime(1100) }) + shift = 80 + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + it('scrolls once to the file each navigation names when its changes arrive', async () => { const m = mount({ params: { path: 'docs/gone.txt' } }) expect(scrolled).not.toHaveBeenCalled() @@ -1323,9 +1438,10 @@ describe('line review actions', () => { expect(tree.getByRole('button', { name: 'src/app.ts' })).toBeTruthy() }) it('refuses a local reference passed to the PR publication callback', async () => { - const renderDiff = DiffFileModule.DiffFile + // The memoized section draws through its inner component, which the spy observes. + const renderDiff = DiffFileModule.DiffFile.type let publish: ReviewActions['publish'] - vi.spyOn(DiffFileModule, 'DiffFile').mockImplementation((props) => { + vi.spyOn(DiffFileModule.DiffFile, 'type').mockImplementation((props) => { publish = props.review?.publish return renderDiff(props) }) diff --git a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts index 51f6ac0d66..92b5a6bdbc 100644 --- a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts @@ -306,6 +306,8 @@ describe('forgePanel.follow', () => { expect(moved.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' }) expect(opened.provider.probes).toEqual(['fake.test', 'other.test']) + // A new commit gives the next inspection a state of its own to publish. + await git(repo, 'commit', '--allow-empty', '-m', 'again') shellTurn(opened.session, 2, [{ callId: 'again', command: 'ls', output: '' }]) await follower.next(state => state.checkout.state === 'ready') expect(follower.states.at(-1)?.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' }) @@ -726,6 +728,41 @@ describe('forgePanel change request diffs', () => { .rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'down' }) }) + it('reads the patch and the .gitattributes together and cancels the other read when one fails', async () => { + const { repo } = await project() + const opened = await open(repo) + shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await reader(opened).until(state => polled(state, pullUrl(7))) + const read = async (): Promise => await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal) + const cancelled: string[] = [] + const held = (name: string) => async (operation: AbortSignal): Promise => { + await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) }) + cancelled.push(name) + throw operation.reason + } + + // Failed reads are not kept, so each case reads again. + const diff = vi.spyOn(opened.provider, 'diff').mockImplementation(async (_ref, _head, operation) => await held('patch')(operation)) + const text = vi.spyOn(opened.provider, 'fileText').mockResolvedValue({ code: 'unavailable', message: 'attributes down' }) + await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'attributes down' }) + diff.mockResolvedValue({ code: 'unavailable', message: 'patch down' }) + text.mockImplementation(async (_ref, _commit, _path, operation) => await held('attributes')(operation)) + await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'patch down' }) + + // The patch read finishes only once the attributes read has started, so reading them one after the other never ends. + const attributesStarted = Promise.withResolvers() + diff.mockImplementation(async () => { + await attributesStarted.promise + return PATCH + }) + text.mockImplementation(async () => { + attributesStarted.resolve(undefined) + return 'src/** linguist-generated\n' + }) + await expect(read()).resolves.toMatchObject({ files: [{ path: 'src/a.ts', layer: 'test' }] }) + expect(cancelled).toEqual(['patch', 'attributes']) + }) + it('lists the commits of a change request and reads consecutive ones from the first one\'s parent', async () => { const { repo } = await project() const opened = await open(repo) @@ -1726,13 +1763,12 @@ describe('forgePanel actions', () => { expect(settled).toBe(false) inspection.release() await inspected - await follower.next(() => true) const probe = Promise.withResolvers() opened.provider.readinessImpl = () => probe.promise settled = false const probed = opened.ctx.forgePanel.refresh(opened.session, signal).then(() => { settled = true }) - await follower.next(() => true) + await vi.waitFor(() => { expect(opened.provider.probes).toHaveLength(3) }) await settle() expect(settled).toBe(false) probe.resolve({ state: 'ready', message: null }) @@ -1740,6 +1776,24 @@ describe('forgePanel actions', () => { expect(opened.provider.probes).toHaveLength(3) }) + it('publishes nothing when a reinspection and a sign-in probe find what the panel already shows', async () => { + const { repo } = await project() + const opened = await open(repo) + const follower = reader(opened) + await follower.until(state => ready(state) && state.readiness !== null) + await settle() + const published = follower.states.length + const inspections = opened.sandbox.calls.length + + shellTurn(opened.session, 1, [{ callId: 'list', command: 'ls', output: '' }]) + await opened.ctx.forgePanel.refresh(opened.session, signal) + await settle() + + expect(opened.sandbox.calls.length).toBeGreaterThan(inspections) + expect(opened.provider.probes).toHaveLength(2) + expect(follower.states).toHaveLength(published) + }) + it('refreshes a Session outside a checkout without polling change requests that no provider serves', async () => { const opened = await open(await plain()) shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: 'https://unserved.test/a/b/pull/3\n' }]) @@ -1749,8 +1803,10 @@ describe('forgePanel actions', () => { await opened.ctx.forgePanel.refresh(opened.session, signal) - const refreshed = await follower.next(state => state.checkout.state === 'none') - expect(refreshed.changeRequests).toEqual([{ url: 'https://unserved.test/a/b/pull/3', origin: 'created', ref: null, update: null, snapshot: null }]) + // The refresh found the same state, which the panel already published. + const refreshed = follower.states.at(-1) + expect(refreshed?.checkout.state).toBe('none') + expect(refreshed?.changeRequests).toEqual([{ url: 'https://unserved.test/a/b/pull/3', origin: 'created', ref: null, update: null, snapshot: null }]) expect(opened.sandbox.calls.length).toBeGreaterThan(inspections) expect(opened.provider.fetches).toEqual([]) expect(opened.provider.probes).toEqual([]) diff --git a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx index 6abac1781d..33f82ad73c 100644 --- a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx @@ -20,9 +20,10 @@ function mount(overrides: Partial = {}) { const addToInput = vi.fn().mockReturnValue(true) const records = new ReviewQueue('diff-review') const review: ReviewActions = { source, addToInput, records, onQuoted: (item) => { records.recordAgent(item) } } - const props: DiffFileProps = { file, visible: true, expanded: true, onToggle: vi.fn(), large: false, revealed: false, onReveal: vi.fn(), - text: undefined, open: new Set(), onExpand: vi.fn(), split: true, wrap: true, badge: '', tooltip: 'a.txt', actions: [], - viewed: 'unviewed', onViewed: vi.fn(), sectionRef: vi.fn(), review, t, ...overrides } + const handlers = { toggle: vi.fn(), reveal: vi.fn(), expand: vi.fn(), view: vi.fn(), actions: () => [], section: vi.fn() } + const props: DiffFileProps = { file, expanded: true, large: false, revealed: false, + text: undefined, open: new Set(), split: true, wrap: true, badge: '', tooltip: 'a.txt', + viewed: 'unviewed', handlers, review, t, ...overrides } const view = render() const line = (name: string) => view.getByRole('button', { name: `Select line ${name}` }) const code = (side: 'L' | 'R', no: number): HTMLElement => { diff --git a/packages/experimental/client-ui-forge/tests/harness.ts b/packages/experimental/client-ui-forge/tests/harness.ts index 961aded76f..5eeaaa6e98 100644 --- a/packages/experimental/client-ui-forge/tests/harness.ts +++ b/packages/experimental/client-ui-forge/tests/harness.ts @@ -347,13 +347,13 @@ export class FakeProvider implements ForgeProvider { return Promise.resolve(this.actionResult) } - diff(ref: ChangeRequestRef, headSha: string): Promise { + diff(ref: ChangeRequestRef, headSha: string, _signal: AbortSignal): Promise { this.diffs.push(ref.number) this.diffHeads.push(headSha) return Promise.resolve(this.patches.get(ref.number) ?? { code: 'not-found', message: `no diff of #${ref.number}` }) } - fileText(_ref: ChangeRequestRef, commit: string, path: string): Promise { + fileText(_ref: ChangeRequestRef, commit: string, path: string, _signal: AbortSignal): Promise { return Promise.resolve(this.texts.get(`${commit}:${path}`) ?? { code: 'not-found', message: `no ${path} at ${commit}` }) } diff --git a/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts b/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts new file mode 100644 index 0000000000..3533147a0c --- /dev/null +++ b/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +/** File sections reach the scrollport once, and deferred highlighting waits for idle time. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SectionReach, whenIdle } from '../src/client/section-reach.ts' + +/** An IntersectionObserver double that records its options and the elements it watches. */ +class FakeObserver { + static readonly made: FakeObserver[] = [] + readonly watched = new Set() + disconnected = false + constructor(readonly report: IntersectionObserverCallback, readonly options: IntersectionObserverInit | undefined) { + FakeObserver.made.push(this) + } + observe(element: Element): void { this.watched.add(element) } + unobserve(element: Element): void { this.watched.delete(element) } + disconnect(): void { + this.disconnected = true + this.watched.clear() + } + /** Report each element as intersecting or not. */ + fire(entries: readonly (readonly [Element, boolean])[]): void { + // The production callback reads only the entries, so the double passes itself as the observer. + Reflect.apply(this.report, undefined, [entries.map(([target, isIntersecting]) => ({ target, isIntersecting })), this]) + } +} + +afterEach(() => { + vi.unstubAllGlobals() + FakeObserver.made.length = 0 +}) + +describe('SectionReach', () => { + it('reaches every section at once where the browser has no IntersectionObserver', () => { + vi.stubGlobal('IntersectionObserver', undefined) + const reach = new SectionReach() + reach.attach(document.createElement('div')) + const reached = vi.fn() + const stop = reach.observe(document.createElement('section'), reached) + expect(reached).toHaveBeenCalledOnce() + stop() + }) + + it('reaches a section once it intersects the scrollport margin and moves waiting sections to a new scrollport', () => { + vi.stubGlobal('IntersectionObserver', FakeObserver) + const reach = new SectionReach() + const near = document.createElement('section') + const far = document.createElement('section') + const gone = document.createElement('section') + const reachedNear = vi.fn() + const reachedFar = vi.fn() + reach.observe(near, reachedNear) + reach.observe(far, reachedFar) + const stopGone = reach.observe(gone, vi.fn()) + expect(FakeObserver.made).toHaveLength(0) + + const root = document.createElement('div') + reach.attach(root) + const first = FakeObserver.made[0]! + expect(first.options).toEqual({ root, rootMargin: '1200px 0px' }) + expect([...first.watched]).toEqual([near, far, gone]) + stopGone() + expect([...first.watched]).toEqual([near, far]) + + first.fire([[near, true], [far, false]]) + expect(reachedNear).toHaveBeenCalledOnce() + expect(reachedFar).not.toHaveBeenCalled() + expect([...first.watched]).toEqual([far]) + + const other = document.createElement('div') + reach.attach(other) + expect(first.disconnected).toBe(true) + const second = FakeObserver.made[1]! + expect(second.options?.root).toBe(other) + expect([...second.watched]).toEqual([far]) + second.fire([[far, true]]) + expect(reachedFar).toHaveBeenCalledOnce() + + reach.attach(null) + expect(second.disconnected).toBe(true) + expect(FakeObserver.made).toHaveLength(2) + }) +}) + +describe('whenIdle', () => { + it('waits for an idle period, at most the timeout, and can be cancelled', () => { + const request = vi.fn<(task: () => void, options: { timeout: number }) => number>(() => 7) + const cancel = vi.fn() + vi.stubGlobal('requestIdleCallback', request) + vi.stubGlobal('cancelIdleCallback', cancel) + const task = vi.fn() + const stop = whenIdle(task) + expect(request).toHaveBeenCalledWith(task, { timeout: 500 }) + stop() + expect(cancel).toHaveBeenCalledWith(7) + }) + + it('runs after queued work where the browser has no requestIdleCallback', () => { + vi.useFakeTimers() + try { + vi.stubGlobal('requestIdleCallback', undefined) + const ran = vi.fn() + whenIdle(ran) + const cancelled = vi.fn() + whenIdle(cancelled)() + vi.runAllTimers() + expect(ran).toHaveBeenCalledOnce() + expect(cancelled).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) From c51ecb2bff643e0c756c13c28ecdccc75e0a1c69 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 16:31:24 +0800 Subject: [PATCH 45/81] feat(cua): request system pointer access on the target mouse move --- ...cua-hover-routes-and-coordinates.i18n.yaml | 4 +- ...-10-13-cua-hover-routes-and-coordinates.md | 2 +- ...-13-cua-hover-routes-and-coordinates.zh.md | 2 +- docs/config-catalog.md | 2 + docs/config-catalog.zh.md | 2 + .../cua-hover-ax-only/guide.i18n.yaml | 8 +- .../v0.2.2-rc.1/cua-hover-ax-only/guide.md | 4 +- .../v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md | 4 +- .../experimental/cua-macos/README.i18n.yaml | 8 +- packages/experimental/cua-macos/README.md | 6 +- packages/experimental/cua-macos/README.zh.md | 6 +- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 2 +- .../cua-macos/native/README.zh.md | 2 +- .../experimental/cua-macos/src/guidance.ts | 2 +- packages/experimental/cua-macos/src/index.ts | 7 +- .../cua-macos/tests/guidance.spec.ts | 6 +- .../tests/protocol-generation.spec.ts | 1 - .../tests/provider-lifecycle.spec.ts | 231 ++++++++++-------- packages/experimental/cua/README.i18n.yaml | 4 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/README.zh.md | 2 +- packages/experimental/cua/src/api.ts | 14 +- .../cua/src/application-access.ts | 13 +- packages/experimental/cua/src/guidance.ts | 3 +- packages/experimental/cua/src/index.ts | 34 ++- packages/experimental/cua/src/locales.ts | 16 +- .../experimental/cua/src/mouse-control.ts | 15 +- .../experimental/cua/src/native-schema.ts | 4 +- packages/experimental/cua/src/protocol.ts | 14 +- packages/experimental/cua/src/types.ts | 11 +- packages/experimental/cua/tests/api.spec.ts | 17 +- .../session/cua-native/replay.override.json | 4 +- 33 files changed, 263 insertions(+), 193 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml index 7681ea3630..4450e114ec 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml @@ -9,8 +9,8 @@ en: f96bf1ebc0719fb0 zh: e9a1144f0e76572d /agent-note-background-hover-and-screenshot-coordinates/decision: - en: 93631d45c7ecfb4c - zh: 77a08aafb1115ba1 + en: 42f40e78c2fff8a8 + zh: b7c5fa5fe538ed77 /agent-note-background-hover-and-screenshot-coordinates/alternatives-considered: en: 5fc4beaf79761c50 zh: 4948333b0020be28 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md index 1f5c18ce74..c46dc02060 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md @@ -10,7 +10,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer. ## Decision -By default, native hover uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. After the user approves `cua.requestMouseControl()`, each `mouseMove` in that session uses the system pointer until DSH restarts. This lets a caller reach pointer-tracking controls even when the same target also advertises AX alternate UI. The Host passes the session decision with each move. Physical movement leaves the visibility of previously revealed controls to the application, so switching routes cannot dismiss a target before dispatch. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). +By default, native hover uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. With `requireSystemPointerApproval` enabled, the first explicit `mouseMove(target, { useSystemPointer: true })` asks the user. Approval applies to this move and later ordinary moves in the live session. Closing the session or restarting DSH clears it. Without the approval requirement only an explicit request selects real input. This lets a caller reach pointer-tracking controls even when the same target also advertises AX alternate UI. The Host passes the session decision with each move. Physical movement leaves the visibility of previously revealed controls to the application, so switching routes cannot dismiss a target before dispatch. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for `click`, `mouseMove`, `scroll` and `drag`; it does not apply to opaque element IDs. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md index d1a14ee9e4..f4c5c39d69 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -默认情况下,原生悬停会寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。用户同意 `cua.requestMouseControl()` 后,本会话的每次 `mouseMove` 都使用系统光标,重启 DSH 后授权失效。有些控件既支持 AX 备用界面,又跟踪真实鼠标。Host 在每次悬停请求中传入本会话的授权结果,让调用方能够触发后者。切换到系统鼠标后,由应用决定是否收起先前展开的控件,以免派发前目标消失。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 +默认情况下,原生悬停会寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。启用 `requireSystemPointerApproval` 时,首次显式调用 `mouseMove(target, { useSystemPointer: true })` 会询问用户。同意后,本次移动和本会话后续普通悬停使用系统光标。关闭会话或重启 DSH 后授权失效。免询问配置下,只有显式请求才会移动系统光标。有些控件既支持 AX 备用界面,又跟踪真实鼠标。Host 在每次悬停请求中传入本会话的授权结果,让调用方能够触发后者。切换到系统鼠标后,由应用决定是否收起先前展开的控件,以免派发前目标消失。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 面向模型的坐标默认使用返回截图中的像素。`click`、`mouseMove`、`scroll` 和 `drag` 传入 `relative: true` 时接受 0~999 的千分比整数,仅用于坐标目标。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 07e8c52196..26a136abb0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1163,6 +1163,8 @@ export interface Config { export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** Ask before a main task may move the system pointer. When false only explicitly requested moves use it. */ + requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8308df3e6f..c7dfeff61e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1165,6 +1165,8 @@ export interface Config { export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** Ask before a main task may move the system pointer. When false only explicitly requested moves use it. */ + requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml index 2f886f19df..1a804c0a53 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 6833a7851486f10d zh: 22983f326fb725ce /macos-hover-keeps-the-system-pointer-in-place-by-default/change: - en: 2369a7daf049311a - zh: 2ad1bfb579be4426 + en: e6d2e56ccb50e46a + zh: a788fc19e0771a46 /macos-hover-keeps-the-system-pointer-in-place-by-default/migration: - en: 7c89c67c9acefd25 - zh: 54a0064c30af043c + en: b1070d660b90318c + zh: 2f2ad1b8897dc044 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md index 733a8a7061..e38e026f1c 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md @@ -9,10 +9,10 @@ English | [中文](guide.zh.md) ## Change -On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that advertises both `AXShowAlternateUI` and `AXShowDefaultUI`. Without that route, previous versions moved the visible system cursor. The default now returns `HOVER_UNAVAILABLE` before sending input. Moving the pointer requires the user’s approval for the current session. +On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that advertises both `AXShowAlternateUI` and `AXShowDefaultUI`. Without that route, previous versions moved the visible system cursor. The default now returns `HOVER_UNAVAILABLE` before sending input. By default, explicitly requesting the system pointer asks the user for this session. ## Migration 1. For an available control, use its advertised action or click it directly. Use browser tools for web content that requires page hover. 2. If your profile sets `helperPath`, rebuild the custom helper to support the per-request `useSystemPointer` field and restart the profile. -3. When the task requires a physical hover, call `cua.requestMouseControl()`. After the user approves, `win.mouseMove` on an unobscured foreground target returns `hoverRoute: "system-pointer"` and `systemPointerMoved: true`. This grant expires when DSH restarts. +3. When the task requires physical movement, call `win.mouseMove(target, { useSystemPointer: true })` on an unobscured foreground target. After approval this move reports `systemPointerMoved: true`. Closing the session or restarting DSH clears approval. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md index 84ac6cbe2b..d5111faf05 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md @@ -9,10 +9,10 @@ description: "macOS 的 mouseMove 默认要求控件支持 AX 备用界面,否 ## 变更 -在 macOS 上,`win.mouseMove(target)` 会查找已启用且同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的控件或祖先。找不到时,旧版会移动系统光标,新版默认在发送输入前返回 `HOVER_UNAVAILABLE`。移动系统光标需要用户单独授权当前会话。 +在 macOS 上,`win.mouseMove(target)` 会查找已启用且同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的控件或祖先。找不到时,旧版会移动系统光标,新版默认在发送输入前返回 `HOVER_UNAVAILABLE`。默认配置下,显式请求移动系统光标时会询问用户是否授权当前会话。 ## 迁移 1. 对已经可用的控件,可执行它声明的动作或直接点击。需要网页悬停时可用浏览器工具。 2. 如果 profile 设置了 `helperPath`,请重新构建该 helper,使其支持按请求读取 `useSystemPointer` 字段,然后重启 profile。 -3. 任务确实需要移动鼠标时,调用 `cua.requestMouseControl()`。用户同意后,对前台未受遮挡的目标调用 `win.mouseMove`,结果为 `hoverRoute: "system-pointer"` 和 `systemPointerMoved: true`。重启 DSH 后授权失效。 +3. 任务确实需要移动鼠标时,在前台未受遮挡的目标上调用 `win.mouseMove(target, { useSystemPointer: true })`。用户同意后,本次移动返回 `systemPointerMoved: true`。关闭会话或重启 DSH 后需要重新授权。 diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 7cefb93644..45d7cd7ff8 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,11 +15,11 @@ en: 1777e7bf6785f986 zh: e3daac1074ebb74d /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: c81e5fbf8ff7e110 - zh: f70adab1257ed26d + en: e51a6c3625873430 + zh: 0a6998897532a05f /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: - en: 2391a460e295cfca - zh: 1889a903efb6142d + en: b87eede533256337 + zh: 15c6b1323a60c5b6 /deepseek-ai-dsh-experimental-cua-macos/understand-the-implementation: en: 4e82b85a5aa72eee zh: 4e004b7a205d8688 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 31793059c9..dddb02c2ef 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -60,6 +60,7 @@ Source builds default to `sourceSigning: ad-hoc`, without creating certificates | `agentPreset` | `ptc` | Register tools and guidance only for Agents bound to this preset; `false` registers globally. | | `allowedApps` | `[]` | Legacy path/signature grants, imported once for removal; they do not authorize new bundle-ID identities. | | `helperPath` | package-owned executable | Absolute executable inside the signed helper `.app`; a bare binary fails with `NATIVE_APPLICATION_REQUIRED`. | +| `requireSystemPointerApproval` | `true` | Ask on the first explicit request to move the system pointer. With `false`, only explicitly requested moves use it. | | `sourceSigning` | `ad-hoc` | Source signing: `ad-hoc`, or `{ identity, keychain }` for an existing certificate and keychain path. Ignored for installed packages and `helperPath`. | | `buildTimeoutMs` | `300000` | Maximum native preparation time. | | `timeoutMs` | `30000` | Per-operation deadline; cancellation can leave partial input. | @@ -116,7 +117,7 @@ Use the `cua` API to discover and select the application (`listApps`, `getState` `getWindow` reads its initial tree silently. An owned window without an AX surface returns an empty snapshot and remains available for screenshots and coordinate input. Within one API instance, the facade keeps one current accessibility snapshot per application handle, shared by every app and window object for that handle, including repeated selections. Reading any window replaces that snapshot; element operations use the latest snapshot ID, so use IDs from the latest tree and do not assume an older ID still identifies the same control. A failed read publishes no partial snapshot; read again without repeating the preceding input. Window objects own screenshots and pointer input. Keyboard methods stay on `app` because input follows the app’s current focus. -`mouseMove(target)` defaults to AX hover. An enabled target or ancestor must advertise both `AXShowAlternateUI` and `AXShowDefaultUI`. Moving into its descendants keeps the UI open, and leaving restores it. Without an AX route the helper returns `HOVER_UNAVAILABLE` before sending input. Click a visible control or use `performAction` with an advertised action. For browser content without these AX actions, use browser tools. If these alternatives cannot complete the task, `cua.requestMouseControl()` asks for this session until DSH restarts. On approval, every subsequent hover moves the system pointer to the foreground, unobscured target and leaves it there. +Ordinary `mouseMove(target)` defaults to AX hover. Without both `AXShowAlternateUI` and `AXShowDefaultUI` on an enabled target or ancestor, it returns `HOVER_UNAVAILABLE` without prompting. Only when the task requires physical movement should the main task call `win.mouseMove(target, { useSystemPointer: true })`. On approval the Host rechecks the window and point, dispatches this move, and uses the visible pointer for later ordinary moves. Refusal sends no input and suppresses further questions. Closing the session or restarting DSH clears the decision. Explicit `useSystemPointer: false` selects AX for one call. `paste(text, { format: "text" | "html" })` temporarily publishes the requested clipboard format and invokes the application’s enabled standard Paste menu command. It keeps the temporary format until a pasteboard reader requests its data, then restores every captured original format while it still owns the clipboard, preserving a later copy. Paste operations share a cross-process lock and cancellation joins helper cleanup. A forced process exit can prevent restoration. Unsupported Paste commands fail without typing fallback; `typeText` remains separate key-event input. @@ -187,7 +188,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index bb9e052a8c..17fd51d708 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -60,6 +60,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. | `agentPreset` | `ptc` | 仅为绑定该 preset 的 Agent 注册工具和指引;`false` 表示全局注册。 | | `allowedApps` | `[]` | 旧路径和签名许可,只导入 profile 存储一次供撤销,不授予新 Bundle ID 身份访问权限。 | | `helperPath` | 包内可执行文件 | 已签名 helper `.app` 内可执行文件的绝对路径;裸二进制会触发 `NATIVE_APPLICATION_REQUIRED`。 | +| `requireSystemPointerApproval` | `true` | 主任务首次显式请求移动系统指针时询问用户。设为 `false` 时仅本次显式移动免询问。 | | `sourceSigning` | `ad-hoc` | 源码签名:`ad-hoc` 临时签名,或 `{ identity, keychain }` 指定已有证书及钥匙串路径。安装版和 `helperPath` 忽略此项。 | | `buildTimeoutMs` | `300000` | 原生组件准备的最长时间。 | | `timeoutMs` | `30000` | 单次操作截止时间,取消后可能已发生部分输入。 | @@ -116,7 +117,7 @@ void await window.getScreenshot() `getWindow` 静默读取初始树。已确认归属但没有 AX 界面的窗口返回空快照,仍可截图和发送坐标输入。在单个 API 实例内,门面按应用句柄保存一份当前辅助功能快照,该句柄的所有应用对象和窗口对象共享它,包括重复选择。读取任一窗口都会替换该快照;元素操作使用最新快照 ID,因此应使用最新树中的元素 ID,不应假定旧 ID 仍指向同一控件。读取失败不会发布部分快照;可以重新读取,但不要重复此前的输入。窗口对象负责截图和指针输入。键盘方法保留在 `app` 上,因为输入跟随应用的当前焦点。 -`mouseMove(target)` 默认通过 AX 悬停。目标或其祖先必须已启用,并同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`。进入子控件会保持展开,离开后恢复默认界面。如果没有可用的 AX 路径,helper 返回 `HOVER_UNAVAILABLE`,不发送输入。可点击已经可见的控件,或用 `performAction` 执行控件声明的动作。浏览器内容缺少这些 AX 动作时,请使用浏览器工具。只有其他操作都无法完成任务时,才调用 `cua.requestMouseControl()` 询问用户。用户同意后,本会话后续悬停会把系统光标移到前台未受遮挡的目标并停在那里,重启 DSH 后授权失效。 +普通 `mouseMove(target)` 默认通过辅助功能悬停。如果目标或其祖先没有同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`,则返回 `HOVER_UNAVAILABLE`,不会弹窗。只有任务确实需要移动用户的鼠标时,主任务才调用 `win.mouseMove(target, { useSystemPointer: true })`。用户允许后,Host 重新检查窗口和目标位置,执行这一次移动,此后本会话的普通悬停也使用系统指针。拒绝后不移动,也不再提问。关闭会话或重启 DSH 后决定失效。传入 `useSystemPointer: false` 可在本次调用中使用辅助功能。 `paste(text, { format: "text" | "html" })` 临时提供指定格式的剪贴板内容,并调用应用已启用的标准 Paste 菜单命令。临时格式保留到剪贴板读取方请求其数据,然后只在仍持有剪贴板时恢复保存的全部原格式,保留期间产生的新复制内容。粘贴操作共用跨进程锁,取消会等待辅助进程清理。强制终止进程可能阻止恢复。应用不支持 Paste 命令时明确失败,不自动改成逐字输入;`typeText` 仍单独提供按键事件输入。 @@ -187,7 +188,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index 16dc733352..cae002d8da 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2a69d89b7fc95998 zh: 084575bb31fb40dd /dsh-cua-native-helper/observation-and-input: - en: 88ad57b61e873f32 - zh: ff8a89697a6c0cc5 + en: 7e63469ddd8c4e72 + zh: dbc87b8d8953c7e7 /dsh-cua-native-helper/verification: en: c87945aeec587bf5 zh: e5d69e4927e2fed3 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index 65ad850f18..16d1e102f0 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -123,7 +123,7 @@ Tree observation and action discovery treat a generic AX failure reading one ele `paste` publishes one plain-text or HTML clipboard format, dispatches the application's enabled standard Paste menu command, and waits for a clipboard data request. It then restores the preserved formats only while its ownership marker and change count still match. The Host serializes its pastes across helpers with a file lock. maxClipboardBytes bounds both input and preserved contents; unsupported menus and oversized data fail before dispatch. A later copy is preserved. Cancellation restores owned clipboard data when possible; forced termination cannot guarantee restoration, and a data request does not prove the target completed editing. After dispatch, `pasteReadTimeoutMs` bounds waiting for clipboard consumption. Expiry returns `PASTE_NOT_CONSUMED`, closes the promised data provider, and restores the original clipboard only while still owned. The failure does not prove the app will never process its queued Paste command; inspect the target before deciding whether to retry. -`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. After session approval, the Host sends `useSystemPointer: true` on each `mouseMove` to select the system pointer directly. +`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. The Host resolves the requested route against session approval or the configured exemption before sending `useSystemPointer` to the helper. The opt-in system pointer route returns `FOREGROUND_REQUIRED` if the target is obscured or belongs to a background app. The result reports the route and whether the visible cursor moved in `hoverRoute` and `systemPointerMoved`. It never presses a button or restores the cursor after a read. Later input may interrupt the hover. Clicks send their button events at the target without an off-window priming click. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 8dc1427c7e..7acb51fd17 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -123,7 +123,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 `paste` 提供一种纯文本或 HTML 剪贴板格式,调用应用已启用的标准 Paste 菜单命令,并等待剪贴板数据请求。随后仅在所有权标记和修改计数仍匹配时恢复原有格式。Host 通过文件锁串行化不同 helper 的粘贴。maxClipboardBytes 同时限制输入和保留内容,不支持的菜单和过大的数据在分派前失败。后续复制会被保留。取消会尽可能恢复仍持有的剪贴板数据,强制结束不保证恢复,数据请求也不证明目标已完成编辑。 分派完成后,`pasteReadTimeoutMs` 限制等待剪贴板读取的时间。到期返回 `PASTE_NOT_CONSUMED`,关闭延迟数据提供方,并仅在仍持有剪贴板时恢复原内容。该失败不能证明应用以后不会处理已排队的 Paste 命令;决定是否重试前先检查目标内容。 -默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。用户授权后,Host 在本会话的每次 `mouseMove` 请求中传入 `useSystemPointer: true`,直接使用系统光标。 +默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。Host 根据本会话的授权或免询问配置决定本次悬停的路径,再向 helper 传入 `useSystemPointer`。 开启后,系统指针路线会检查前台应用和坐标命中的窗口,不满足条件时返回 `FOREGROUND_REQUIRED`。返回值 `hoverRoute` 和 `systemPointerMoved` 说明走了哪条路线以及是否移动了可见光标。悬停不会按下鼠标,也不会在读取后还原光标。后续输入可能中断悬停。点击只在目标点发送按键事件,不先在窗口外点击。 diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index b1ab6ad168..63a81e0b40 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -6,7 +6,7 @@ export const PLATFORM_GUIDANCE = 'macOS: accessibility tree operations are avail const methodDetails = new Map([ ['getAccessibilityTree', 'If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current.'], - ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again.'], + ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again.'], ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) diff --git a/packages/experimental/cua-macos/src/index.ts b/packages/experimental/cua-macos/src/index.ts index 757cc11050..26142f654c 100644 --- a/packages/experimental/cua-macos/src/index.ts +++ b/packages/experimental/cua-macos/src/index.ts @@ -16,7 +16,7 @@ const CAPABILITIES: CuaStartupCapabilities = { platform: 'macos', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', - 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'requestMouseControl', 'screenshot', 'scroll', + 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', 'selectText', 'sendKeyEvent', 'setValue', 'show', 'typeText', ], appLevelAccessibility: true, @@ -33,6 +33,8 @@ export const inject = ['computerUse', 'tools', 'systemPrompt', 'subprocess', 'ag export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** Ask before a main task may move the system pointer. When false only explicitly requested moves use it. */ + requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. @@ -43,6 +45,7 @@ export interface Config extends CuaConfig { /** Validate shared operation bounds and macOS source signing before preparing the helper. */ export const Config = Schema.object({ ...CuaConfigFields, + requireSystemPointerApproval: Schema.boolean().default(true), permissionPollIntervalMs: Schema.number().step(1).min(100).max(2 ** 31 - 1).default(1000), sourceSigning: Schema.union([ Schema.const('ad-hoc'), @@ -62,7 +65,7 @@ export const Config = Schema.object({ */ export async function apply(ctx: Context, config: Config): Promise { const backend = { createChannel: createSharedNativeChannel, sendKeyEvent: installHeldKeys(ctx), - guidance: GUIDANCE, capabilities: CAPABILITIES, + guidance: GUIDANCE, capabilities: CAPABILITIES, requireSystemPointerApproval: config.requireSystemPointerApproval, permissionParameters: createPermissionParameters(config.permissionPollIntervalMs) } if (config.helperPath !== undefined) { if (!isAbsolute(config.helperPath)) throw new Error('dsh-cua helperPath must be absolute') diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index a0f6ac2339..7f0cb9ea86 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -4,7 +4,7 @@ import type { CuaApi, CuaApp, CuaSurface, CuaWindow } from '@deepseek-ai/dsh-cua import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' import { GUIDANCE, PLATFORM_GUIDANCE } from '../src/guidance.ts' -const apiNames: Array = ['getCapabilities', 'requestMouseControl', 'listApps', 'findApps', 'getState', 'getApp'] +const apiNames: Array = ['getCapabilities', 'listApps', 'findApps', 'getState', 'getApp'] const accessibilityNames: Array> = [ 'getAccessibilityTree', 'listActions', 'invoke', 'performAction', 'setValue', 'selectText', ] @@ -56,8 +56,8 @@ it('enumerates every available method once in its catalog entry', () => { for (const catalog of [COMMON_GUIDANCE, GUIDANCE]) { for (const signature of signatures) expect(catalog.split('\n').filter(line => line.startsWith(`- ${signature}`))).toHaveLength(1) } - expect(signatures).toHaveLength(25) - expect(new Set(signatures.map(signature => signature.slice(signature.indexOf('.') + 1, -1))).size).toBe(24) + expect(signatures).toHaveLength(24) + expect(new Set(signatures.map(signature => signature.slice(signature.indexOf('.') + 1, -1))).size).toBe(23) expect(COMMON_GUIDANCE).toContain('app.appHandle') expect(COMMON_GUIDANCE).toContain('window.windowId') }) diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index c5ca6b90c0..36c6dd94c9 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -33,7 +33,6 @@ describe('native protocol generation', () => { { method: 'drag', params: { appHandle: 'app', windowId: 1, fromX: 1, fromY: 1, toX: 2, toY: 2, steps: 1 } }, { method: 'paste', params: { appHandle: 'app', text: 'text', maxClipboardBytes: 1024 } }, { method: 'paste', params: { appHandle: 'app', text: 'text', pasteReadTimeoutMs: 1000 } }, - { method: 'mouseMove', params: { appHandle: 'app', windowId: 1, x: 0, y: 0, useSystemPointer: true } }, { method: 'findApps', params: { query: 'Editor', maxAppMatches: 1 } }, { method: 'releaseHeldKeys', params: { runId: 'execution' } }, { method: 'launchApp', params: { appHandle: 'pending' } }, diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 52202a0774..ea69351f0d 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -67,7 +67,7 @@ vi.mock('@deepseek-ai/dsh-cua/native-client', async importOriginal => ({ })) const config = { - permissionPollIntervalMs: 1000, + permissionPollIntervalMs: 1000, requireSystemPointerApproval: true, agentPreset: false, helperPath: resolve('fixture-helper'), sourceSigning: 'ad-hoc', buildTimeoutMs: 300_000, timeoutMs: 10_000, connectTimeoutMs: 5000, screenshotTimeoutMs: 5000, axTimeoutMs: 3000, maxRequestBytes: 1024, maxResponseBytes: 4096, maxClipboardBytes: 8192, pasteReadTimeoutMs: 1000, maxAppMatches: 20, graceMs: 10, textEventIntervalMs: 8, maxClickHoldMs: 7000, maxElements: 64, maxDepth: 8, dragDurationMs: 200, dragSteps: 10, @@ -77,6 +77,7 @@ const { helperPath: _helperPath, ...automaticConfig } = config it('defaults to ad-hoc signing and requires a keychain path for certificate signing', () => { expect(NativeProvider.Config({}).sourceSigning).toBe('ad-hoc') + expect(NativeProvider.Config({}).requireSystemPointerApproval).toBe(true) expect(NativeProvider.Config({ sourceSigning: 'ad-hoc' }).sourceSigning).toBe('ad-hoc') for (const sourceSigning of [{ identity: 'Apple Development: Example', keychain: '/keychains/custom.keychain-db' }, { identity: 'ABC123', keychain: '/keychains/custom.keychain-db' }]) { expect(NativeProvider.Config({ sourceSigning }).sourceSigning).toEqual(sourceSigning) @@ -290,7 +291,7 @@ it('merges startup capabilities into runtime native fields', async () => { expect(result.value).toEqual({ ...runtime, platform: 'macos', appLevelAccessibility: true, keyboard: ['app'], browser: false, coordinateSpace: 'screenshot-pixels', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', - 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'requestMouseControl', 'screenshot', 'scroll', + 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', 'selectText', 'sendKeyEvent', 'setValue', 'show', 'typeText', ] }) expect(native.request).toHaveBeenCalledExactlyOnceWith('getCapabilities', {}, expect.any(AbortSignal), ToolCallId('lifecycle-1')) @@ -970,111 +971,149 @@ it('routes permission actions through the macOS provider with the Client languag }, expect.any(AbortSignal)) }) -it('moves only for the consenting session, without granting application access or retaining consent after reload', async () => { +const hoverTarget = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } +const physicalHover = { ...hoverTarget, useSystemPointer: true } + +async function hoverFixture(requireApproval = true) { const f = await setup() - await f.ctx.plugin(UserQuestions) - const ask = vi.fn((id: string) => ({ answers: [{ id, selected: ['Allow for this session'] }] })) - f.ctx.on('user-questions/request', async ({ questions }) => ask(questions[0]!.id)) - const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } - const application = { appHandle: 'app-one', name: 'Fixture', running: true, - authorization: { id: 'a'.repeat(64), name: 'Fixture', persistable: false } } - native.request.mockResolvedValueOnce(application) - expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false) - const move = async (useSystemPointer: boolean) => { - expect((await f.execute('mouseMove', target)).isError).toBe(false) - expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...target, useSystemPointer }, expect.any(AbortSignal), expect.any(String)) + if (!requireApproval) { + await f.provider.dispose() + await f.ctx.plugin(NativeProvider, { ...config, requireSystemPointerApproval: false }) } - await move(false) - expect((await f.execute('mouseMove', { ...target, useSystemPointer: true })).isError).toBe(true) - for (let i = 0; i < 2; i++) expect((await f.execute('requestMouseControl')).value).toBe(true) - await move(true) - const other = await f.ctx.agents.create({ sessionId: SessionId('other-mouse-session') }) - const otherCall = (method: string, params: Record) => executeToolFixture(f.ctx, { - agent: other.agent, signal: new AbortController().signal, callId: ToolCallId(method), name: 'cua', arguments: { method, params }, + const questions = await f.ctx.plugin(UserQuestions) + const questionId = 'cua-mouse-control' + const approved = { answers: [{ id: questionId, selected: ['Allow for this session'] }] } + const ask = vi.fn(async () => approved) + f.ctx.on('user-questions/request', async ({ questions }) => questions[0]!.id === questionId ? ask() + : { answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] }) + let index = 0 + const call = (agent: typeof f.handle.agent, method: string, params: Record) => executeToolFixture(f.ctx, { + agent, signal: new AbortController().signal, callId: ToolCallId(`hover-${++index}`), name: 'cua', arguments: { method, params }, }) - expect(JSON.stringify((await otherCall('mouseMove', target)).content)).toContain('APP_ACCESS_REQUIRED') - native.request.mockResolvedValueOnce(application) - expect((await otherCall('getApp', { selector: 'Fixture' })).isError).toBe(false) - ask.mockImplementationOnce(id => ({ answers: [{ id, selected: ['Deny'] }] })) - expect((await otherCall('requestMouseControl', {})).value).toBe(false) - expect((await otherCall('mouseMove', target)).isError).toBe(false) - expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...target, useSystemPointer: false }, expect.any(AbortSignal), expect.any(String)) - await move(true) + const select = async (agent = f.handle.agent) => { + native.request.mockResolvedValueOnce({ appHandle: 'app-one', name: 'Fixture', running: true, + authorization: { id: 'a'.repeat(64), name: 'Fixture', persistable: false } }) + expect((await call(agent, 'getApp', { selector: 'Fixture' })).isError).toBe(false) + } + await select() + native.request.mockClear() + return { ...f, ask, approved, call, select, questions } +} + +it('asks only on an explicit move and dispatches that same move after approval', async () => { + const f = await hoverFixture() + native.request.mockRejectedValueOnce(new Error('HOVER_UNAVAILABLE')) + expect(JSON.stringify((await f.execute('mouseMove', hoverTarget)).content)).toContain('HOVER_UNAVAILABLE') + expect(f.ask).not.toHaveBeenCalled() + const entered: PromiseWithResolvers = Promise.withResolvers() + const answer = Promise.withResolvers() + f.ask.mockImplementationOnce(() => { entered.resolve(); return answer.promise }) + onTestFinished(() => { answer.resolve(f.approved) }) + const pending = f.execute('mouseMove', physicalHover) + await entered.promise + expect(native.request).toHaveBeenCalledTimes(1) + expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('MOUSE_CONTROL_PENDING') + answer.resolve(f.approved) + expect((await pending).isError).toBe(false) + for (const [params, physical] of [ + [hoverTarget, true], [{ ...hoverTarget, useSystemPointer: false }, false], [physicalHover, true], + ] as const) { + expect((await f.execute('mouseMove', params)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: physical }, expect.any(AbortSignal), expect.any(String)) + } + expect(native.request.mock.calls[1]?.[1]).toEqual(physicalHover) + expect(f.ask).toHaveBeenCalledTimes(1) +}) + +it('keeps refusals and grants separate and forgets a closed session on reopen', async () => { + const f = await hoverFixture() + expect((await f.execute('mouseMove', physicalHover)).isError).toBe(false) + const { agent } = await f.ctx.agents.create({ sessionId: SessionId('other-mouse-session') }) + expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('APP_ACCESS_REQUIRED') + await f.select(agent) + f.ask.mockResolvedValueOnce({ answers: [{ id: 'cua-mouse-control', selected: ['Deny'] }] }) + native.request.mockClear() + for (let i = 0; i < 2; i++) expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('MOUSE_CONTROL_DENIED') + expect(native.request).not.toHaveBeenCalled() + expect((await f.call(agent, 'mouseMove', hoverTarget)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: false }, expect.any(AbortSignal), expect.any(String)) + expect((await f.execute('mouseMove', hoverTarget)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', physicalHover, expect.any(AbortSignal), expect.any(String)) + const sessionId = f.handle.agent.id + await f.handle.dispose() + const reopened = await f.ctx.agents.create({ sessionId }) + await f.select(reopened.agent) + expect((await f.call(reopened.agent, 'mouseMove', hoverTarget)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: false }, expect.any(AbortSignal), expect.any(String)) + expect((await f.call(reopened.agent, 'mouseMove', physicalHover)).isError).toBe(false) + expect(f.ask).toHaveBeenCalledTimes(3) +}) + +it('rechecks the selection after a pending approval and does not dispatch revoked or stale input', async () => { + const f = await hoverFixture() + f.ask.mockImplementationOnce(async () => { + await f.ctx.cuaAccess.revoke('a'.repeat(64) as Parameters[0]) + return f.approved + }) + expect((await f.execute('mouseMove', physicalHover)).isError).toBe(true) + expect(native.request).not.toHaveBeenCalled() +}) + +it('revalidates window coordinates after the user answers', async () => { + const f = await hoverFixture() + native.request.mockResolvedValueOnce([{ windowId: 7, appName: 'Fixture', title: 'Window', bounds: { x: 0, y: 0, width: 100, height: 100 }, + isOnScreen: true, onCurrentSpace: true }]) + await f.execute('listWindows', { appHandle: 'app-one' }) + f.ask.mockImplementationOnce(async () => { + native.request.mockResolvedValueOnce([]) + await f.execute('listWindows', { appHandle: 'app-one' }) + return f.approved + }) + const result = await f.execute('mouseMove', { appHandle: 'app-one', windowId: 7, x: 500, y: 500, relative: true, useSystemPointer: true }) + expect(JSON.stringify(result.content)).toContain('STALE_SNAPSHOT') + expect(native.request.mock.calls.map(([name]) => name)).toEqual(['listWindows', 'listWindows']) +}) + +it('drops dismissal, aborted answers and missing human answerers without allowing input', async () => { + const f = await hoverFixture() + f.ask.mockRejectedValueOnce(new UserQuestionError('dismissed', 'ASK_CANCELLED')) + expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('MOUSE_APPROVAL_CANCELLED') + const controller = new AbortController() + f.ask.mockImplementationOnce(async () => { controller.abort(); return f.approved }) + await Promise.allSettled([f.execute('mouseMove', physicalHover, controller.signal)]) + expect(native.request).not.toHaveBeenCalled() + expect((await f.execute('mouseMove', physicalHover)).isError).toBe(false) + expect(f.ask).toHaveBeenCalledTimes(3) await f.provider.dispose() await f.ctx.plugin(NativeProvider, config) - expect((await f.execute('requestMouseControl')).value).toBe(true) - expect(ask.mock.calls.map(([id]) => id)).toEqual(['cua-app-access', 'cua-mouse-control', 'cua-app-access', 'cua-mouse-control', 'cua-mouse-control']) + await f.select() + await f.questions.dispose() + expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('MOUSE_APPROVAL_UNAVAILABLE') }) -it.each(['Deny', 'custom'] as const)('remembers a %s answer without moving the pointer or asking again', async (answer) => { - const f = await setup() - await f.ctx.plugin(UserQuestions) - const ask = vi.fn(() => { - return { answers: [{ id: 'cua-mouse-control', selected: [answer === 'custom' ? 'Allow for this session' : answer], - ...(answer === 'custom' ? { custom: 'maybe later' } : {}) }] } - }) - f.ctx.on('user-questions/request', async () => ask()) - expect((await f.execute('requestMouseControl')).value).toBe(false) - expect((await f.execute('requestMouseControl')).value).toBe(false) - expect(ask).toHaveBeenCalledTimes(1) - expect(native.request).not.toHaveBeenCalled() +it('allows only the explicitly requested physical move when approval is disabled', async () => { + const f = await hoverFixture(false) + for (const [target, expected] of [[hoverTarget, false], [physicalHover, true], [hoverTarget, false]] as const) { + expect((await f.execute('mouseMove', target)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: expected }, expect.any(AbortSignal), expect.any(String)) + } + expect(f.ask).not.toHaveBeenCalled() }) -it('allows a fresh request after dismissal without authorizing the first one', async () => { - const f = await setup() - await f.ctx.plugin(UserQuestions) - const ask = vi.spyOn(f.ctx.userQuestions, 'ask') - .mockRejectedValueOnce(new UserQuestionError('User dismissed the request', 'ASK_CANCELLED')) - .mockResolvedValueOnce({ answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }] }) - const dismissed = await f.execute('requestMouseControl') - expect(dismissed.isError).toBe(true) - expect(JSON.stringify(dismissed.content)).toContain('MOUSE_APPROVAL_CANCELLED') - expect((await f.execute('requestMouseControl')).value).toBe(true) - expect(ask).toHaveBeenCalledTimes(2) - expect(native.request).not.toHaveBeenCalled() -}) - -it('refuses delegated mouse control before asking the user even when the parent has approval', async () => { - const f = await setup() - await f.ctx.plugin(UserQuestions) - const ask = vi.spyOn(f.ctx.userQuestions, 'ask').mockResolvedValue({ - answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }], - }) - expect((await f.execute('requestMouseControl')).value).toBe(true) - ask.mockClear() +it.each([true, false])('rejects a delegated pointer move before asking or dispatching with approval=%s', async (approval) => { + const f = await hoverFixture(approval) const { agent } = await f.ctx.agents.create({ sessionId: SessionId('delegated-mouse-session'), meta: { origin: 'subagent', parentSession: f.handle.agent.id } }) - const refused = await executeToolFixture(f.ctx, { agent, signal: new AbortController().signal, - callId: ToolCallId('delegated-mouse-control'), name: 'cua', arguments: { method: 'requestMouseControl', params: {} } }) - expect(refused.isError).toBe(true) + // This case starts with application access already granted to the child. + const selectAnswer = vi.spyOn(f.ctx.userQuestions, 'ask') + .mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] }) + await f.select(agent) + selectAnswer.mockClear() + native.request.mockClear() + const refused = await f.call(agent, 'mouseMove', physicalHover) expect(JSON.stringify(refused.content)).toContain('MOUSE_CONTROL_UNAVAILABLE') - expect(JSON.stringify(refused.content)).toContain('report that step in your final result for the main task') - expect(ask).not.toHaveBeenCalled() - expect(native.request).not.toHaveBeenCalled() -}) - -it('drops late approval after cancellation and admits only one pending question', async () => { - const f = await setup() - expect((await f.execute('requestMouseControl')).isError).toBe(true) - await f.ctx.plugin(UserQuestions) - const entered = Promise.withResolvers() - let count = 0 - const release = Promise.withResolvers() - f.ctx.on('user-questions/request', async () => { - count++ - entered.resolve(undefined) - await release.promise - return { answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }] } - }) - const controller = new AbortController() - const first = f.execute('requestMouseControl', {}, controller.signal) - await entered.promise - expect(JSON.stringify((await f.execute('requestMouseControl')).content)).toContain('MOUSE_CONTROL_PENDING') - controller.abort() - release.resolve(undefined) - await Promise.allSettled([first]) - expect((await f.execute('requestMouseControl')).value).toBe(true) - expect(count).toBe(2) + expect(selectAnswer).not.toHaveBeenCalled() + expect(f.ask).not.toHaveBeenCalled() expect(native.request).not.toHaveBeenCalled() }) @@ -1089,8 +1128,8 @@ it('keeps provider-specific pointer flags and questions off providers without mo keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, }) } }) - expect(JSON.stringify((await f.execute('requestMouseControl')).content)).toContain('UNSUPPORTED_OPERATION') await selectApplication(f) + expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('UNSUPPORTED_OPERATION') const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } expect((await f.execute('mouseMove', target)).isError).toBe(false) expect(native.request).toHaveBeenLastCalledWith('mouseMove', target, expect.any(AbortSignal), expect.any(String)) diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index b72fd91584..62318feee3 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -15,8 +15,8 @@ en: 5db4d4fea25493b9 zh: 65ab64fc24eb0d09 /deepseek-ai-dsh-cua/use-this-package: - en: a55ba012aedca751 - zh: fcddea780c0ec58e + en: 5e95c138cb107385 + zh: b126c56fb7d35538 /deepseek-ai-dsh-cua/use-this-package/permission-controls: en: d92d5948a7941e8d zh: d43491ae6521b87e diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index 39a3e522d8..c3d2ce62c8 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -41,7 +41,7 @@ Observation methods emit and return their result. Use `void await app.getAccessi Application consent precedes launch, observation, capture, and input. Session access is stored by conversation and application identity and survives DSH restart and Computer Use disablement. Other conversations require their own consent. Saved grants require a platform-provided reusable application identity. Refusal and revocation close affected Agent connections, so their old objects require fresh selection. No input is replayed after cancellation, timeout, or reconnect. -Use `cua.requestMouseControl()` only when the task requires moving the user’s pointer. Approval applies to `mouseMove` in this session until DSH restarts. Actions return a `dispatched` boolean; `mouseMove` also reports `hoverRoute` and `systemPointerMoved`, and `show()` adds `windowId` and `foregroundChanged`. Read fresh state to verify the result. Screenshots and pointer actions are available only on explicit window objects. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 999 after a successful window observation. Values are divided by 1000 and scaled to the window dimensions, then clamped inside the window. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to the provider’s native window coordinates. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. +On supported providers the main task can request a physical move with `win.mouseMove(target, { useSystemPointer: true })`. Actions return a `dispatched` boolean; `mouseMove` also reports `hoverRoute` and `systemPointerMoved`, and `show()` adds `windowId` and `foregroundChanged`. Read fresh state to verify the result. Screenshots and pointer actions are available only on explicit window objects. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 999 after a successful window observation. Values are divided by 1000 and scaled to the window dimensions, then clamped inside the window. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to the provider’s native window coordinates. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. `click(target, { holdMs: 800 })` holds the mouse button for 800 milliseconds before release. `holdMs` applies only to `click`; it accepts nonnegative integers and applies to each press of a double-click; zero releases without an additional hold. The provider config `maxClickHoldMs` limits explicit holds and defaults to 5000 ms. Explicit holds use pointer input; omission preserves ordinary click behavior. The operation deadline includes the hold, and cancellation or timeout releases the button. Received input cannot be undone; forced helper termination cannot guarantee release. diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index c2933dc0d1..bde2ccd1a3 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 应用授权发生在启动、观察、截图和输入之前。会话许可按对话和应用身份持久化,重启 DSH 或关闭 Computer Use 后仍可复用,其他对话需要单独授权。长期许可要求平台提供可复用的应用身份。拒绝或撤销会关闭受影响的 Agent 连接,因此旧对象必须重新选择目标。取消、超时或重新连接后均不重放输入。 -只有任务确实需要移动用户的鼠标时,才调用 `cua.requestMouseControl()` 询问用户。授权仅适用于当前会话的 `mouseMove`,重启 DSH 后失效。动作返回 `dispatched` 布尔值;`mouseMove` 还会返回 `hoverRoute` 和 `systemPointerMoved`,`show()` 还会返回 `windowId` 和 `foregroundChanged`。请读取新状态以验证结果。截图和指针操作只能通过显式窗口对象使用。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~999 的整数坐标。数值除以 1000 后按窗口尺寸缩放,并限制在窗口内。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为提供方的原生窗口坐标。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 +在支持系统鼠标的提供方上,主任务可通过 `win.mouseMove(target, { useSystemPointer: true })` 请求真实移动。动作返回 `dispatched` 布尔值;`mouseMove` 还会返回 `hoverRoute` 和 `systemPointerMoved`,`show()` 还会返回 `windowId` 和 `foregroundChanged`。请读取新状态以验证结果。截图和指针操作只能通过显式窗口对象使用。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~999 的整数坐标。数值除以 1000 后按窗口尺寸缩放,并限制在窗口内。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为提供方的原生窗口坐标。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 `click(target, { holdMs: 800 })` 按住鼠标按钮 800 毫秒后松开。`holdMs` 仅适用于 `click`;它接受非负整数,双击时应用于每次按下;0 表示按下后立即松开,不额外等待。提供方配置 `maxClickHoldMs` 限制显式按住时长,默认 5000 毫秒。指定时长后使用鼠标事件;省略时保留普通点击行为。按住时间计入操作超时,取消或超时会松开按钮。已接收的输入无法撤销;辅助进程被强制终止时无法保证松开。 diff --git a/packages/experimental/cua/src/api.ts b/packages/experimental/cua/src/api.ts index 930db84195..a33f4ca9a7 100644 --- a/packages/experimental/cua/src/api.ts +++ b/packages/experimental/cua/src/api.ts @@ -1,6 +1,6 @@ /** Object facade over logged native operations; its caller owns evaluator persistence. */ import { Buffer } from 'node:buffer' -import { z } from 'zod' +import type { z } from 'zod' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { ActionNamesResult, ActionResult, AppResult, AppsResult, CapabilitiesResult, FoundAppsResult, HoverResult, Request, ScreenshotResult, ShowResult, StateResult, WindowsResult } from './protocol.ts' import type { CuaApi, CuaApp, CuaAppHandle, CuaCapabilities, CuaDispatch, CuaObservationOptions, CuaObservationSink, CuaOperation, CuaScreenshot, CuaSurface, CuaWindow } from './types.ts' @@ -51,9 +51,6 @@ export function createCuaApi(dispatch: CuaDispatch, sink?: CuaObservationSink): const objects = createCuaObjects(call, sink) const api: CuaApi = { - async requestMouseControl() { - return z.boolean().parse(await call('requestMouseControl', {})) - }, async getCapabilities(options) { const result = CapabilitiesResult.parse(await call('getCapabilities', {})) capabilities = result @@ -203,12 +200,15 @@ function createCuaObjects(call: ReturnType, sink: CuaObser return screenshot }, async mouseMove(targetPoint, options) { - const relative = options?.relative === undefined ? {} : { relative: options.relative } + const movement = { + ...options?.relative === undefined ? {} : { relative: options.relative }, + ...options?.useSystemPointer === undefined ? {} : { useSystemPointer: options.useSystemPointer }, + } if (typeof targetPoint === 'string') { - return HoverResult.parse(await base.call('mouseMove', { ...base.elementParams(), elementId: targetPoint, ...relative })) + return HoverResult.parse(await base.call('mouseMove', { ...base.elementParams(), elementId: targetPoint, ...movement })) } const [x, y] = targetPoint - return HoverResult.parse(await base.call('mouseMove', { ...coordinateParams(appHandle, windowId), x, y, ...relative })) + return HoverResult.parse(await base.call('mouseMove', { ...coordinateParams(appHandle, windowId), x, y, ...movement })) }, async click(targetPoint, options) { const mouseButton = options?.mouseButton ?? 'left' diff --git a/packages/experimental/cua/src/application-access.ts b/packages/experimental/cua/src/application-access.ts index 02066411e1..32bc31b811 100644 --- a/packages/experimental/cua/src/application-access.ts +++ b/packages/experimental/cua/src/application-access.ts @@ -231,16 +231,17 @@ export class ApplicationAccess { * @param handle Selected application. * @param signal Calling tool lifetime. * @param dispatch One native operation, with revocation cancellation. + * @param prepare Optional interactive preparation outside the application lock. Dispatch rechecks access after it settles. * @returns The native result, with no implicit retry after consent or cancellation. */ async run(client: NativeClient, handle: CuaAppHandle, signal: AbortSignal, - dispatch: (signal: AbortSignal) => Promise): Promise { + dispatch: (signal: AbortSignal) => Promise, prepare?: (signal: AbortSignal) => Promise): Promise { const state = this.clients.get(client) const selected = state?.handles.get(handle) if (!state || !selected) throw new NativeError('APP_ACCESS_REQUIRED', 'Select the application with getApp and obtain user consent before reading or controlling it') if (selected.lifetime.signal.aborted) throw new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application') const lifetime = AbortSignal.any([signal, this.lifetime.signal, selected.lifetime.signal]) - return this.options.store.withApplication(selected.identity.id, lifetime, async () => { + const requireAccess = async () => { const permitted = await this.options.store.withState(lifetime, ({ state: permissions }) => this.permitted(selected, permissions.applications[selected.identity.id] ?? this.empty())) if (!permitted) { @@ -248,6 +249,14 @@ export class ApplicationAccess { throw new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application') } if (client.disposed) throw new NativeError('NATIVE_CLOSED', 'Helper is closed; acquire fresh application handles') + } + if (prepare) { + await requireAccess() + await prepare(lifetime) + lifetime.throwIfAborted() + } + return this.options.store.withApplication(selected.identity.id, lifetime, async () => { + await requireAccess() return this.withOperation(state.agent, client, selected.identity, lifetime, dispatch) }) } diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index a729901370..d11a575e37 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -8,7 +8,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -33,7 +32,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index 410225856a..24f216fb78 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -127,6 +127,8 @@ export interface CuaBackend { createChannel: NativeChannelFactory guidance: string capabilities: CuaStartupCapabilities + /** Whether explicit system-pointer moves require consent. Omit for providers without this route. */ + requireSystemPointerApproval?: boolean /** Dispatch a directional key through the provider's execution-scoped input owner, after application authorization. */ sendKeyEvent?: (client: NativeClient, params: Record, execution: Readonly) => Promise @@ -160,7 +162,8 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) permissionParameters: backend.permissionParameters, store: new CuaPermissionStore(ctx, config.allowedApps.map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), config.timeoutMs), }) - const mouseControl = backend.capabilities.methods.includes('requestMouseControl') ? new MouseControl(ctx) : undefined + const mouseControl = backend.requireSystemPointerApproval === undefined + ? undefined : new MouseControl(ctx, backend.requireSystemPointerApproval) const clients = new Map() const lifetime = new AbortController() const pending = new Map, Agent>() @@ -209,7 +212,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) for (const result of [...await permissions, ...results, ...releases]) if (result.status === 'rejected') errors.push(result.reason) if (errors.length > 0) throw new AggregateError(errors, 'dsh-cua helper cleanup failed') } - const callNative = async (request: CuaOperation, execution: Readonly) => { + const callNative = async (request: CuaOperation, execution: Readonly, pointerRoute?: boolean) => { const { method, params: parsedParams } = request const params = execution.coordinates.parameters(request) const agent = execution.agent @@ -226,7 +229,10 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) ? { ...params, maxElements: config.maxElements, maxDepth: config.maxDepth } : method === 'drag' ? { ...params, durationMs: config.dragDurationMs, steps: config.dragSteps } : params - if (method === 'mouseMove' && mouseControl) nativeParams.useSystemPointer = mouseControl.allowed(agent) + if (method === 'mouseMove') { + delete nativeParams.useSystemPointer + if (mouseControl) nativeParams.useSystemPointer = pointerRoute ?? mouseControl.allowed(agent) + } const requestNative = () => client.request(method, nativeParams, signal, execution.callId) if (method === 'screenshot') { const { attachments, target } = await screenshotStore(ctx, execution) @@ -313,10 +319,6 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const signal = AbortSignal.any([execution.signal, lifetime.signal, owner.signal]) signal.throwIfAborted() const operation = (async () => { - if (request.method === 'requestMouseControl') { - if (!mouseControl) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') - return mouseControl.request(agent, signal) - } const previous = clients.get(agent) if (previous?.disposed) { try { await previous.dispose() } catch (error) { @@ -342,14 +344,28 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) owner.coordinates = new CuaCoordinates() } const nativeClient = client + let pointerRoute: boolean | undefined + let prepare: ((signal: AbortSignal) => Promise) | undefined + if (request.method === 'mouseMove') { + if (request.params.useSystemPointer === true) { + prepare = async (signal) => { + if (!mouseControl) throw new NativeError('UNSUPPORTED_OPERATION', 'This provider does not support system mouse control') + owner.coordinates.parameters(request) + if (!await mouseControl.request(agent, signal)) { + throw new NativeError('MOUSE_CONTROL_DENIED', 'The user declined system-pointer movement for this session. No input was sent. Use pointer-free actions.') + } + pointerRoute = true + } + } else pointerRoute = request.params.useSystemPointer + } const dispatch = async (signal: AbortSignal) => { const scoped = { ...execution, agent, signal, nativeClient, coordinates: owner.coordinates } - const result = await callNative(request, scoped) + const result = await callNative(request, scoped, pointerRoute) signal.throwIfAborted() return result } return 'appHandle' in request.params - ? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch) + ? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch, prepare) : dispatch(signal) })() pending.set(operation, agent) diff --git a/packages/experimental/cua/src/locales.ts b/packages/experimental/cua/src/locales.ts index 7c2cba49eb..fbd9b7ac43 100644 --- a/packages/experimental/cua/src/locales.ts +++ b/packages/experimental/cua/src/locales.ts @@ -117,14 +117,14 @@ export const mouseControlDisplay = { pt: 'Permitir que o agente mova o ponteiro do mouse?', }, detail: { - en: 'Hovering will move your pointer and leave it at the target, even while you are using the mouse. This permission applies only to this session until DSH restarts. If you decline, this session will not ask again before restart.', - zh: '智能体悬停时会移动你的鼠标指针,并将它留在目标位置,即使你正在使用鼠标。授权仅限当前会话,重启 DSH 后失效。如果拒绝,重启前本会话不会再次询问。', - ja: 'ホバーすると、マウスを使用中でもポインタが移動し、移動先に残ります。許可はこのセッションに限り、DSH を再起動すると失効します。拒否すると、再起動までこのセッションでは再度確認しません。', - es: 'Al pasar sobre un elemento, el puntero se moverá y quedará allí aunque estés usando el ratón. El permiso solo se aplica a esta sesión y caduca al reiniciar DSH. Si lo deniegas, esta sesión no volverá a preguntar hasta que reinicies DSH.', - fr: 'Lors d’un survol, le pointeur se déplace et reste sur la cible, même si vous utilisez la souris. Cette autorisation vaut pour cette session et expire au redémarrage de DSH. Si vous refusez, cette session ne redemandera pas avant le redémarrage.', - de: 'Beim Überfahren eines Ziels bewegt sich der Zeiger und bleibt dort, auch wenn Sie die Maus verwenden. Die Erlaubnis gilt nur für diese Sitzung und erlischt beim Neustart von DSH. Wenn Sie ablehnen, fragt diese Sitzung bis zum Neustart nicht erneut.', - ru: 'При наведении указатель переместится и останется на цели, даже если вы пользуетесь мышью. Разрешение действует только в этом сеансе до перезапуска DSH. Если вы откажете, запрос в этом сеансе не повторится до перезапуска.', - pt: 'Ao passar sobre um elemento, o ponteiro se moverá e ficará no destino, mesmo enquanto você usa o mouse. A permissão vale apenas para esta sessão e expira ao reiniciar o DSH. Se você recusar, esta sessão não perguntará novamente até a reinicialização.', + en: 'Allowing moves your pointer to this target now. Later hovers can also move it. If you decline, this session will not ask again. The decision expires when you close the session or restart DSH.', + zh: '允许后,你的鼠标指针会立即移到目标位置,本会话后续悬停也会移动指针。如果拒绝,本会话不会再询问。关闭会话或重启 DSH 后,这个决定失效。', + ja: '許可するとポインタが今すぐ移動し、以後のホバーでも移動します。拒否すると、このセッションでは再度確認しません。セッションを閉じるか DSH を再起動すると、この選択は失効します。', + es: 'Si aceptas, el puntero se moverá ahora al destino y también podrá moverse al pasar sobre otros elementos. Si rechazas, no se volverá a preguntar. La decisión caduca al cerrar la sesión o reiniciar DSH.', + fr: 'Si vous acceptez, le pointeur se déplace immédiatement sur la cible, puis lors des survols suivants. Si vous refusez, la question ne sera plus posée. Ce choix expire à la fermeture de la session ou au redémarrage de DSH.', + de: 'Bei Zustimmung bewegt sich der Zeiger sofort zum Ziel und auch bei späteren Mausbewegungen. Bei Ablehnung wird nicht erneut gefragt. Die Entscheidung gilt, bis Sie die Sitzung schließen oder DSH neu starten.', + ru: 'При разрешении указатель сразу переместится на цель и сможет перемещаться при следующих наведениях. При отказе вопрос не повторится. Решение действует до закрытия сеанса или перезапуска DSH.', + pt: 'Se permitir, o ponteiro irá para o alvo agora e poderá se mover nas próximas ações. Se recusar, a pergunta não será repetida. A decisão expira ao encerrar a sessão ou reiniciar o DSH.', }, approve: applicationAccessDisplay.approve, } diff --git a/packages/experimental/cua/src/mouse-control.ts b/packages/experimental/cua/src/mouse-control.ts index 595cef0f0b..8482554943 100644 --- a/packages/experimental/cua/src/mouse-control.ts +++ b/packages/experimental/cua/src/mouse-control.ts @@ -7,9 +7,9 @@ import { NativeError } from './native-client.ts' /** Mouse control applies only to mouseMove and never grants access to an application. */ export class MouseControl { - private readonly decisions = new Map() + private readonly decisions = new WeakMap() - constructor(private readonly ctx: Context) {} + constructor(private readonly ctx: Context, private readonly requireApproval: boolean) {} /** * Select the native route without prompting the user. @@ -17,7 +17,7 @@ export class MouseControl { * @returns Whether this Session explicitly approved moving the system pointer. */ allowed(agent: Agent): boolean { - return this.decisions.get(agent.id) === true + return this.decisions.get(agent) === true } /** @@ -30,12 +30,13 @@ export class MouseControl { if (agent.session.header.origin === 'subagent') { throw new NativeError('MOUSE_CONTROL_UNAVAILABLE', "Delegated tasks cannot move the user's pointer. Use AX hover, click, or performAction. If the task requires a physical hover, report that step in your final result for the main task.") } - const decision = this.decisions.get(agent.id) + if (!this.requireApproval) return true + const decision = this.decisions.get(agent) if (decision === 'asking') throw new NativeError('MOUSE_CONTROL_PENDING', 'Wait for the pending mouse control request in this session before continuing') if (decision !== undefined) return decision const questions = this.ctx.get('userQuestions') if (!questions) throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', 'No human answerer is available for mouse control') - this.decisions.set(agent.id, 'asking') + this.decisions.set(agent, 'asking') const id = 'cua-mouse-control' const approve = mouseControlDisplay.approve.en const reject = 'Deny' @@ -49,10 +50,10 @@ export class MouseControl { const [answer] = result.answers const granted = result.answers.length === 1 && answer?.id === id && !answer.custom?.trim() && answer.selected.length === 1 && answer.selected[0] === approve - this.decisions.set(agent.id, granted) + this.decisions.set(agent, granted) return granted } catch (error) { - this.decisions.delete(agent.id) + this.decisions.delete(agent) if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { throw new NativeError('MOUSE_APPROVAL_CANCELLED', 'The mouse control request was dismissed. Continue without mouse control unless the user asks to reconsider', { cause: error }) } diff --git a/packages/experimental/cua/src/native-schema.ts b/packages/experimental/cua/src/native-schema.ts index 72487f2108..5bb061383f 100644 --- a/packages/experimental/cua/src/native-schema.ts +++ b/packages/experimental/cua/src/native-schema.ts @@ -1,7 +1,7 @@ /** Build-time native admission metadata derived from the shared operation definitions. */ import { z } from 'zod' import type { JSONSchema } from 'zod/v4/core' -import { NativeOnlyOperations, NativeResourceParameters, NativeToolOperations, type Request } from './protocol.ts' +import { NativeOnlyOperations, NativeResourceParameters, Request } from './protocol.ts' type Schema = JSONSchema.JSONSchema @@ -26,7 +26,7 @@ export function nativeProtocol( json: string } { const resources = new Map(Object.entries({ ...NativeResourceParameters, ...platformFields })) - const schemas = [...NativeToolOperations, ...NativeOnlyOperations, ...privateOperations] + const schemas = [...Request.options, ...NativeOnlyOperations, ...privateOperations] .map(schema => z.toJSONSchema(schema)) const fields = new Map>() const enums = new Map>>() diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 161da04ee7..d3f0d6a0b6 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -34,8 +34,10 @@ const imageBase64 = z.string().min(1).refine(value => Buffer.from(value, 'base64 message: 'Expected canonical base64 image data', }) -/** Operations shared by the model and native helper, before Host parameters are added. */ -export const NativeToolOperations = [ +const mouseMove = { useSystemPointer: z.boolean().optional().describe('Request use of the system pointer. The Host checks permission before dispatch.') } + +/** Model-submittable operations, before the Host applies permission decisions and resource limits. */ +export const Request = z.discriminatedUnion('method', [ operation('getCapabilities', {}), operation('listApps', {}), operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }), operation('getApp', { selector: z.string().min(1) }), @@ -50,7 +52,8 @@ export const NativeToolOperations = [ operation('performAction', { ...snapshot, action: z.string().min(1), argument: jsonValue.optional() }), operation('invoke', snapshot), z.object({ method: z.literal('mouseMove'), params: z.union([ - z.object(snapshot).strict(), z.object(absoluteCoordinate).strict(), z.object(relativeCoordinate).strict(), + z.object({ ...snapshot, ...mouseMove }).strict(), z.object({ ...absoluteCoordinate, ...mouseMove }).strict(), + z.object({ ...relativeCoordinate, ...mouseMove }).strict(), ]) }).strict(), z.object({ method: z.literal('click'), params: z.union([ z.object({ ...snapshot, ...click }).strict(), @@ -77,10 +80,7 @@ export const NativeToolOperations = [ fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(), ]) }).strict(), operation('screenshot', window), -] as const - -/** Model-submittable operations. The Host answers mouse-control requests without dispatching native input. */ -export const Request = z.discriminatedUnion('method', [...NativeToolOperations, operation('requestMouseControl', {})]) +]) /** Resource parameters supplied by the Host, never accepted from model tool arguments. */ export const NativeResourceParameters = { diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index 77923d64be..7c596c87d4 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -196,10 +196,11 @@ export interface CuaWindow extends CuaSurface { * Returning and subsequent observations do not restore the previous pointer position. * Other input and application changes can interrupt the interaction; observe before continuing. * @param target - current element id or screenshot-local [x,y]. - * @param options - coordinate mode; applies only to [x,y]. + * @param options - Coordinate mode for points and optional system-pointer request. False explicitly selects pointer-free hover. * @returns dispatch evidence and the selected route; observe the resulting UI after any UI delay. */ - mouseMove(target: CuaElementId | readonly [number, number], options?: CuaCoordinateOptions): Promise + mouseMove(target: CuaElementId | readonly [number, number], + options?: CuaCoordinateOptions & { useSystemPointer?: boolean }): Promise /** * Click a current element id or screenshot-local point. * Cancellation or timeout releases the held button; received input cannot be undone. The operation deadline includes holdMs. @@ -246,12 +247,6 @@ export interface CuaApp extends CuaSurface { } /** Native app API; its caller owns global bindings and variable persistence. */ export interface CuaApi { - /** - * Ask to move the user's pointer with mouseMove for this session until restart. Only the main task can ask. - * @returns True on approval, false on refusal. Both decisions last until restart for this session. - * @throws If the user dismisses the question without deciding or another request is pending. - */ - requestMouseControl(): Promise /** * Inspect permissions without requesting them. * @param options - output options. diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index 053010af02..50eb722493 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -891,10 +891,15 @@ it('expands menu contents and details only for the requested observation', async expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'getState', params: { appHandle: selected.appHandle, includeMenus: false, details: false } }) }) -it('requests mouse control without dispatching a move and returns the decision', async () => { - const dispatch = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) - const cua = createCuaApi(dispatch) - expect(await cua.requestMouseControl()).toBe(false) - expect(await cua.requestMouseControl()).toBe(true) - expect(dispatch.mock.calls).toEqual(Array(2).fill([{ method: 'requestMouseControl', params: {} }])) +it('passes explicit system-pointer intent for both mouseMove target forms', async () => { + const f = fixture() + const win = await (await f.api.getApp('Editor')).getWindow(23) + await win.mouseMove(element('0'), { useSystemPointer: true }) + expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'mouseMove', params: { + appHandle: 'app-17', windowId: 23, snapshotId: 'snapshot-2', elementId: '0', useSystemPointer: true, + } }) + await win.mouseMove([250, 750], { relative: true, useSystemPointer: false }) + expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'mouseMove', params: { + appHandle: 'app-17', windowId: 23, snapshotId: 'snapshot-2', x: 250, y: 750, relative: true, useSystemPointer: false, + } }) }) diff --git a/snapshots/session/cua-native/replay.override.json b/snapshots/session/cua-native/replay.override.json index e6bf49a2c3..eb110a4715 100644 --- a/snapshots/session/cua-native/replay.override.json +++ b/snapshots/session/cua-native/replay.override.json @@ -1431,9 +1431,9 @@ "index": 0, "block": { "type": "tool-call", - "id": "cua-pointer-request", + "id": "cua-explicit-pointer-move", "name": "cua", - "arguments": "{\"method\":\"requestMouseControl\",\"params\":{}}" + "arguments": "{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true,\"useSystemPointer\":true}}" } }, { From def08e19a63ddabb7ccf4604717b4cc1ad282358 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 16:33:10 +0800 Subject: [PATCH 46/81] test(cua): record approval on the requested mouse move --- snapshots/session/cua-native/session.v4.jsonl | 8 ++--- .../cua-native/system-prompt.expected.md | 3 +- .../cua-native/tool-schemas.expected.json | 31 +++++++------------ .../session/cua-ptc/system-prompt.expected.md | 3 +- .../system-prompt.expected.md | 3 +- 5 files changed, 19 insertions(+), 29 deletions(-) diff --git a/snapshots/session/cua-native/session.v4.jsonl b/snapshots/session/cua-native/session.v4.jsonl index 2dc362b2af..8e343139cc 100644 --- a/snapshots/session/cua-native/session.v4.jsonl +++ b/snapshots/session/cua-native/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect CUA capabilities, select the","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889225,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889225,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"requestMouseControl\",\"screenshot\",\"scroll\",\"selectText\",\"sendKeyEvent\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"sendKeyEvent\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889794,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889794,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} @@ -227,9 +227,9 @@ {"type":"tool/result","data":{"turn":1,"step":43,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-hover-unavailable"},"toolCallId":"cua-hover-unavailable","content":[{"type":"text","text":"Error: HOVER_UNAVAILABLE: No AX alternate UI at this target. No input was sent and the pointer did not move."}],"isError":true,"id":"{{message:89}}"}},"sourceEventSeqs":[224],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":43}} {"type":"step/start","data":{"turn":1,"step":44}} -{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890679,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890679,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-pointer-request","name":"cua","arguments":"{\"method\":\"requestMouseControl\",\"params\":{}}"}} -{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-pointer-request"},"toolCallId":"cua-pointer-request","content":[{"type":"text","text":"true"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":44,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-explicit-pointer-move","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true,\"useSystemPointer\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:90}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890679,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890679,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-explicit-pointer-move","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true,\"useSystemPointer\":true}}"}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890679,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":44,"callId":"cua-explicit-pointer-move","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true,\"useSystemPointer\":true}}"}} +{"type":"tool/result","data":{"turn":1,"step":44,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-explicit-pointer-move"},"toolCallId":"cua-explicit-pointer-move","content":[{"type":"text","text":"{\"dispatched\":true,\"hoverRoute\":\"system-pointer\",\"systemPointerMoved\":true}"}],"isError":false,"id":"{{message:91}}"}},"sourceEventSeqs":[229],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":44}} {"type":"step/start","data":{"turn":1,"step":45}} {"type":"assistant/message","data":{"turn":1,"step":45,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:92}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032890696,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032890696,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-system-hover","name":"cua","arguments":"{\"method\":\"mouseMove\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":7,\"x\":100,\"y\":200,\"relative\":true}}"}}},{"type":"chunk","time":1794032890696,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032890696,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index 87c08e7425..037bde95e5 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -43,7 +43,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -68,7 +67,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/cua-native/tool-schemas.expected.json b/snapshots/session/cua-native/tool-schemas.expected.json index 77b7653284..b9bb435797 100644 --- a/snapshots/session/cua-native/tool-schemas.expected.json +++ b/snapshots/session/cua-native/tool-schemas.expected.json @@ -484,6 +484,10 @@ "type": "string", "minLength": 1, "description": "Opaque string defined by the platform provider, valid for at least the lifetime of one snapshot." + }, + "useSystemPointer": { + "description": "Request use of the system pointer. The Host checks permission before dispatch.", + "type": "boolean" } }, "required": [ @@ -521,6 +525,10 @@ "description": "Omitted or false uses pixels in the latest screenshot returned by this tool.", "type": "boolean", "const": false + }, + "useSystemPointer": { + "description": "Request use of the system pointer. The Host checks permission before dispatch.", + "type": "boolean" } }, "required": [ @@ -561,6 +569,10 @@ "type": "boolean", "const": true, "description": "Use integer thousandths of the window width and height, from 0 to 999." + }, + "useSystemPointer": { + "description": "Request use of the system pointer. The Host checks permission before dispatch.", + "type": "boolean" } }, "required": [ @@ -1413,25 +1425,6 @@ "params" ], "additionalProperties": false - }, - { - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "requestMouseControl" - }, - "params": { - "type": "object", - "properties": {}, - "additionalProperties": false - } - }, - "required": [ - "method", - "params" - ], - "additionalProperties": false } ], "$defs": { diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 2155b8c9ea..d5580e4964 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -45,7 +45,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -70,7 +69,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index ed2cfda2e8..3e0d591989 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -45,7 +45,6 @@ Signatures show resolved return values after await. ActionResult = { dispatched: Setup and discovery: - cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. -- cua.requestMouseControl() -> boolean — ask the user to allow mouseMove to move their system pointer for this session until DSH restarts. Only the main task may ask, and only when a physical hover is necessary. A false result is a refusal for this session until restart. If the user dismisses the request, continue without mouse control unless they ask to reconsider. Await the outstanding request before calling again. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -70,7 +69,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only request mouse control if the task cannot be completed with those alternatives. The question interrupts the user, and moving their pointer can disrupt their work. After approval, every mouseMove requires the target app to be in front and the point unobscured, including targets that previously supported background AX hover. If mouseMove returns FOREGROUND_REQUIRED, explain the necessary foreground step before activating the app, then observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user; approval moves their pointer to this target immediately and makes subsequent ordinary moves use it until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. Without an approval requirement only explicitly requested moves use the system pointer. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. From 6f088b13e7d383a69202f28eaca354efea6d4e76 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:42:40 +0800 Subject: [PATCH 47/81] perf(forge-panel): highlight only after scrolling settles Highlighting waits until the page is idle and the scrollport has been still for 200 ms, so a slow grammar's first tokenization never interrupts a scroll. Adds the Agent Note recording the changes view decisions. --- ...orge-changes-view-responsiveness.i18n.yaml | 19 ++++++++++ ...11-07-forge-changes-view-responsiveness.md | 33 +++++++++++++++++ ...07-forge-changes-view-responsiveness.zh.md | 33 +++++++++++++++++ .../client-ui-forge/README.i18n.yaml | 4 +-- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../client-ui-forge/src/client/ChangesTab.tsx | 3 +- .../client-ui-forge/src/client/DiffFile.tsx | 17 +++++---- .../src/client/section-reach.ts | 30 ++++++++++++++++ .../tests/diff-review.client.spec.tsx | 8 ++++- .../tests/section-reach.client.spec.ts | 36 +++++++++++++++++++ 11 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md create mode 100644 .agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml new file mode 100644 index 0000000000..85b111788b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-07-forge-changes-view-responsiveness.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md +/agent-note-forge-changes-view-responsiveness: + en: e809ffea0d880a4b + zh: 6725e6a9bd1c3bd1 +/agent-note-forge-changes-view-responsiveness/problem: + en: 77fab27f3d4a4a59 + zh: 46e0fa8b9ef978a7 +/agent-note-forge-changes-view-responsiveness/decision: + en: 8a0a9ef23a83ce2b + zh: 840d67e79ae39ff1 +/agent-note-forge-changes-view-responsiveness/alternatives-considered: + en: 9d12ccf1d236676d + zh: 0ea63c5c16c62de7 +/agent-note-forge-changes-view-responsiveness/consequences: + en: 4087fff405624d95 + zh: 2f0e577eea4daf40 diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md new file mode 100644 index 0000000000..197552f117 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md @@ -0,0 +1,33 @@ +# Agent Note: Forge changes view responsiveness + +Status: implemented + +English | [中文](2026-11-07-forge-changes-view-responsiveness.zh.md) + +## Problem + +The forge changes view of a large change request froze the browser. Opening #7985 (366 files, 37,563 drawn lines) kept the main thread busy for about five seconds, "expand all" for fifteen, and every later state change, such as a poll result, a tool result, a file jump, or a keystroke in the file filter, re-rendered every file section. While the view was visible, the whole app slowed down: two subject-less `:has()` rules in `ui-sidebar-files` made each DOM change restyle the entire document, and the view's hundred thousand nodes made each restyle cost 170–260 ms, so a streaming Agent turn kept the main thread saturated. + +## Decision + +**Scoped `:has()` rules.** The navigator's covering rules name their row through `data-files-navigator-row`, which both hosts of `sidebar.files.navigator` set, so a DOM change outside such a row schedules no document-wide invalidation. + +**Sections draw what changed and what the reader reaches.** `ChangesTab` passes each memoized `DiffFile` values whose identity survives its renders: one handler object, one comment-actions object, and per-file strings and booleans. Tab visibility reaches only the menus and the composer through a context. An expanded file holds a 22 px row per drawn line until its section first comes within 1200 px of the scrollport, then draws its lines and keeps them. A jump keeps its target aligned for one second while nearby sections draw. + +**Idle-time highlighting.** Lines draw as plain text and take their tokens once the page is idle and the scrollport has been still for 200 ms, so tokenization never interrupts a scroll in progress. A pass highlights 100-line fragments while each idle period lasts and runs again only when the file's segments change or its own language's grammar loads; the shared highlighter's identity, which changes on every grammar load, no longer triggers one. + +**Fewer serial reads.** A panel state equal to the published one reaches no follower. `changeRequestDiff` reads the patch and `.gitattributes` together. Snapshots carry the base commit as `baseSha`; `ForgeService.diff` passes it to the provider, and forge-github starts the compare read while its GraphQL read confirms the head. + +## Alternatives considered + +**`content-visibility: auto` alone** cut style work as much as scoping the rules did, but kept the React render and Shiki tokenization of every expanded file at open, about 3 s of script, and its placeholder sizes would need estimates for every collapsed and expanded section. + +**Full virtualization that also unmounts far sections** would bound the DOM after long scrolls, but a section holds a comment draft and a text selection, which unmounting loses. + +**Highlighting in a Web Worker** removes tokenization from the main thread entirely, including a heavy grammar's first use, but the shared highlighter in `ui-primitives` owns Shiki; moving it is a separate change for every consumer. + +**A provider-private cache of base commits** would avoid the optional `baseSha` field, but it adds retained state without an owner that knows when a change request stops being watched. + +## Consequences + +Opening #7985 now takes 0.5 s of main-thread work instead of 5.2 s, and file jumps, filtering, polling, and Agent turns no longer produce long tasks. In exchange, the browser's find in page does not see lines of files the reader has not approached, highlighting restarts at 100-line fragment boundaries, so a multi-line comment or string crossing one can be mis-colored, and the first use of a slow grammar, such as C++, still produces a 400–550 ms long task while scrolling. A diff read costs a second compare request when the base commit changed after the latest status. diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md new file mode 100644 index 0000000000..80be6bf6fa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Forge 变更视图的响应速度 + +Status: implemented + +[English](2026-11-07-forge-changes-view-responsiveness.md) | 中文 + +## Problem + +大型变更请求的 forge 变更视图会让浏览器卡住。打开 #7985(366 个文件、37,563 个绘制行)时主线程忙约 5 秒,“全部展开”约 15 秒;之后的每次状态变化,例如轮询结果、工具结果、跳转文件或在文件筛选框中按键,都会重渲染所有文件段。视图可见时整个应用都变慢:`ui-sidebar-files` 中两条没有主体的 `:has()` 规则让每次 DOM 变化都重算整个文档的样式,而视图的十万个节点让每次重算耗时 170–260 ms,所以 Agent 流式输出的回合会让主线程一直饱和。 + +## Decision + +**限定 `:has()` 规则。** 导航器的遮盖规则通过 `data-files-navigator-row` 指定所在行,`sidebar.files.navigator` 的两个宿主都会设置它,因此这类行之外的 DOM 变化不再触发全文档失效。 + +**文件段只绘制变化的部分和读者接近的部分。** `ChangesTab` 传给每个 memo 化的 `DiffFile` 的值在多次渲染之间保持同一标识:一个处理函数对象、一个评论操作对象,以及每个文件的字符串和布尔值。标签页可见性通过 context 只传给菜单和评论编辑器。展开的文件在它的段第一次进入滚动区域 1200 px 范围之前,按每个绘制行 22 px 占位,之后绘制各行并一直保留。跳转后的一秒内,目标文件在周围文件段绘制时保持对齐。 + +**空闲时高亮。** 各行先以纯文本绘制,在页面空闲且滚动区域静止 200 ms 后得到 token,因此分词不会打断正在进行的滚动。一次高亮在每个空闲时段内处理多个 100 行的片段,只有文件的分段变化或该语言自己的语法加载完成时才重新运行;共享高亮器的标识在任何语法加载时都会变化,不再触发重新高亮。 + +**减少串行读取。** 与已发布状态相同的面板状态不会通知任何关注方。`changeRequestDiff` 同时读取补丁和 `.gitattributes`。快照把基准提交作为 `baseSha` 携带;`ForgeService.diff` 把它传给提供方,forge-github 在 GraphQL 读取确认 head 的同时就开始 compare 读取。 + +## Alternatives considered + +**只用 `content-visibility: auto`** 减少的样式工作与限定规则相当,但打开时仍要对每个展开文件做 React 渲染和 Shiki 分词,约 3 秒脚本时间,而且每个折叠和展开的段都需要估计占位尺寸。 + +**同时卸载远处文件段的完整虚拟化** 可以在长距离滚动后限制 DOM 规模,但文件段持有评论草稿和文本选区,卸载会丢失它们。 + +**在 Web Worker 中高亮** 能把分词完全移出主线程,包括重型语法的首次使用,但 Shiki 由 `ui-primitives` 中的共享高亮器持有;迁移它是影响所有使用方的另一项改动。 + +**由提供方私有缓存基准提交** 可以省去可选的 `baseSha` 字段,但会引入保留状态,而且没有一个知道变更请求何时不再被 watch 的所有者。 + +## Consequences + +打开 #7985 的主线程工作从 5.2 秒降到 0.5 秒,跳转文件、筛选、轮询和 Agent 回合不再产生长任务。代价是:浏览器的页内查找看不到读者尚未接近的文件中的行;高亮在 100 行片段边界处重新开始,跨越边界的多行注释或字符串可能着色错误;慢语法(例如 C++)首次使用时,滚动中仍会出现 400–550 ms 的长任务。最新状态之后基准提交有变化时,一次 diff 读取会多一次 compare 请求。 diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 5bb4a25d1e..d2e078a9fc 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: c7e1b7d87ccccd2e zh: 42d864b918a6175c /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 0afedb76112dfcb8 - zh: 52d5ddc7a4f9d31c + en: d0756f4b236e1e6f + zh: 9a02f6fa80118c5a /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index ddbd23b094..bc116fbb1d 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -73,7 +73,7 @@ Each inspection also decides whether the worktree is missing: the Session's dire `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. -The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and passes each file section values that keep their identity across its renders, so a render redraws only the sections whose values changed. An expanded file draws its lines once its section first comes within 1200px of the scrollport, keeps them drawn after that, and until then holds a 22px row per drawn line; the lines are highlighted with the shared code highlighter when the page is next idle, again only when the file's segments change or its language's grammar finishes loading; a chip title shows its name with the changes icon. +The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and passes each file section values that keep their identity across its renders, so a render redraws only the sections whose values changed. An expanded file draws its lines once its section first comes within 1200px of the scrollport, keeps them drawn after that, and until then holds a 22px row per drawn line; the lines are highlighted with the shared code highlighter in 100-line fragments when the page is idle and the scrollport has been still for 200 ms, again only when the file's segments change or its language's grammar finishes loading; a chip title shows its name with the changes icon. | Source | Responsibility | |---|---| diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index bd8de3f4bf..81f2bed6f0 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -73,7 +73,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 -浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并且传给每个文件段的值在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内时才绘制各行,此后一直保留;在此之前按每个绘制行 22px 占位。各行在页面下次空闲时用共享的代码高亮器高亮,之后只在文件的分段变化或该语言的语法加载完成时重新高亮;标签页标题显示变更图标与视图名称。 +浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并且传给每个文件段的值在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内时才绘制各行,此后一直保留;在此之前按每个绘制行 22px 占位。各行在页面空闲且滚动区域静止 200 ms 后,用共享的代码高亮器按 100 行一片高亮,之后只在文件的分段变化或该语言的语法加载完成时重新高亮;标签页标题显示变更图标与视图名称。 | 源码 | 职责 | |---|---| diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 8902be11e2..c47a16f9a6 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -611,7 +611,7 @@ export function ChangesTab({ const release = (): void => { cancelAnimationFrame(frame) for (const type of READER_SCROLL) port.removeEventListener(type, release) - if (releasePin.current === release) releasePin.current = null + releasePin.current = null } const keep = (now: number): void => { if (now > until) { @@ -880,6 +880,7 @@ export function ChangesTab({
{ + reach.scrolled() if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null }}> {files.map((file) => { diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index cb6c3ad121..db48c61457 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -160,12 +160,13 @@ function addChunkSpans(spans: FileSpans, chunk: HighlightChunk, highlighter: Cod } /** - * Highlight a file's drawn lines in idle time, once its language's grammar is ready: each idle period highlights - * fragments while it lasts, at least one, and the lines take their tokens when the last fragment is done. Until - * then, the lines keep the tokens of the previous pass, by line number, and new lines draw as plain text. A grammar - * that loads for another language starts no pass. + * Highlight a file's drawn lines in idle time while the scrollport is still, once its language's grammar is ready: + * each idle period highlights fragments while it lasts, at least one, and the lines take their tokens when the last + * fragment is done. Until then, the lines keep the tokens of the previous pass, by line number, and new lines draw as + * plain text. A grammar that loads for another language starts no pass. */ function useFileSpans(path: string, segments: readonly DiffSegment[]): FileSpans | undefined { + const reach = useContext(SectionReachContext) const highlighter = useCodeHighlighter(languageForPath(path)) // The highlighter changes whenever any grammar loads; the empty fragment tells whether this language's is ready. const ready = highlighter('') !== undefined @@ -188,11 +189,13 @@ function useFileSpans(path: string, segments: readonly DiffSegment[]): FileSpans next++ if ((deadline?.timeRemaining() ?? 0) <= 0) break } - cancel = whenIdle(step) + cancel = later(step) } - let cancel = whenIdle(step) + // Outside a changes view nothing scrolls the sections, so idle time alone gates the pass. + const later = reach === null ? whenIdle : (task: (deadline: IdleDeadline | undefined) => void) => reach.whenSettled(task) + let cancel = later(step) return () => { cancel() } - }, [segments, ready]) + }, [segments, ready, reach]) return spans } diff --git a/packages/experimental/client-ui-forge/src/client/section-reach.ts b/packages/experimental/client-ui-forge/src/client/section-reach.ts index d926bd1ee2..1cb3997531 100644 --- a/packages/experimental/client-ui-forge/src/client/section-reach.ts +++ b/packages/experimental/client-ui-forge/src/client/section-reach.ts @@ -9,6 +9,9 @@ import { createContext } from 'react' /** Distance beyond the scrollport's edges within which a section counts as reached. */ const REACH_MARGIN = '1200px 0px' +/** Time, in milliseconds, the scrollport stays still before deferred highlighting runs. */ +const SCROLL_QUIET_MS = 200 + const noop = (): void => {} /** @@ -18,6 +21,33 @@ const noop = (): void => {} export class SectionReach { private observer: IntersectionObserver | undefined private readonly waiting = new Map void>() + private scrolledAt = Number.NEGATIVE_INFINITY + + /** Record that the scrollport scrolled, which holds back work started through {@link whenSettled}. */ + scrolled(): void { + this.scrolledAt = performance.now() + } + + /** + * Run `task` once the page is idle and the scrollport has been still for {@link SCROLL_QUIET_MS}, so work that + * can wait never interrupts a scroll in progress. + * @param task - deferrable work; it receives the idle period, or undefined without one. + * @returns the canceller, which has no effect after `task` ran. + */ + whenSettled(task: (deadline: IdleDeadline | undefined) => void): () => void { + let cancel = noop + const attempt = (deadline: IdleDeadline | undefined): void => { + const wait = this.scrolledAt + SCROLL_QUIET_MS - performance.now() + if (wait <= 0) { + task(deadline) + return + } + const id = setTimeout(() => { cancel = whenIdle(attempt) }, wait) + cancel = () => { clearTimeout(id) } + } + cancel = whenIdle(attempt) + return () => { cancel() } + } /** * Measure sections against `root`, or stop measuring when it unmounts. Sections that are still waiting move to the new root. diff --git a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx index 33f82ad73c..71bc95a914 100644 --- a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom /** Diff gestures, browser clipboard output, and retained comments at their captured lines. */ -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { DiffFile, type DiffFileProps } from '../src/client/DiffFile.tsx' @@ -249,3 +249,9 @@ it('places left and right records in unified rows and retains outdated records f expect(m.container.querySelectorAll('[data-forge-review-thread]')).toHaveLength(3) expect(within(m.container).getByRole('button', { name: /changed lines/ })).toBeTruthy() }) + +it('highlights a section drawn outside a changes view once the page is idle', async () => { + const m = mount({ file: { ...file, path: 'a.ts' } }) + expect(m.container.querySelector('[data-diff-code]')).toBeNull() + await waitFor(() => { expect(m.container.querySelectorAll('[data-diff-code]').length).toBeGreaterThan(0) }) +}) diff --git a/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts b/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts index 3533147a0c..f33a2422ec 100644 --- a/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/section-reach.client.spec.ts @@ -81,6 +81,42 @@ describe('SectionReach', () => { }) }) +describe('SectionReach.whenSettled', () => { + it('waits for the scrollport to stay still before running deferred work, and can be cancelled while it waits', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) + try { + vi.stubGlobal('requestIdleCallback', undefined) + const reach = new SectionReach() + const settled = vi.fn() + reach.whenSettled(settled) + vi.advanceTimersByTime(0) + expect(settled).toHaveBeenCalledOnce() + + reach.scrolled() + const held = vi.fn() + reach.whenSettled(held) + vi.advanceTimersByTime(0) + expect(held).not.toHaveBeenCalled() + vi.advanceTimersByTime(100) + reach.scrolled() + vi.advanceTimersByTime(100) + expect(held).not.toHaveBeenCalled() + vi.advanceTimersByTime(200) + expect(held).toHaveBeenCalledOnce() + + reach.scrolled() + const cancelled = vi.fn() + const stop = reach.whenSettled(cancelled) + vi.advanceTimersByTime(0) + stop() + vi.advanceTimersByTime(500) + expect(cancelled).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) + describe('whenIdle', () => { it('waits for an idle period, at most the timeout, and can be cancelled', () => { const request = vi.fn<(task: () => void, options: { timeout: number }) => number>(() => 7) From b07f93af189d6d1723723647d87f7689ae122779 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 16:46:56 +0800 Subject: [PATCH 48/81] feat(forge-panel): filter review threads by resolution and fit the thread list to narrow views The thread list chooses Unresolved, Resolved, or All in the segmented radio group the lens uses, and drops the Outdated and Awaiting my reply filters with the Awaiting my reply tag. Its footer keeps only View on GitHub, after a note when the read was truncated, and each file group names the file before its directory. The list's width rules queried the changes view's container by name, which CSS Modules rename per file, so they never applied and a narrow view squeezed the files beside a 320px list. The rules now query the nearest container: a view at least 1040px wide docks the list; a narrower one floats it over the files with a scrim, moves the focus into it, and closes it before opening a thread; below 480px it takes the files' whole width with Back to changes. Escape, the scrim, and the close actions return the focus to the Threads button. Below 480px a file header shows the unresolved count alone and thread cards keep 6px margins. --- .../review-thread-list.expected.md | 14 +- apps/web/tests/forge-panel.e2e.ts | 17 ++- .../client-ui-forge/README.i18n.yaml | 4 +- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../src/client/ChangesTab.module.css | 8 ++ .../client-ui-forge/src/client/ChangesTab.tsx | 29 ++-- .../client-ui-forge/src/client/DiffFile.tsx | 5 +- .../src/client/ReviewControls.module.css | 7 +- .../src/client/ReviewControls.tsx | 64 ++++++--- .../src/client/ReviewThreads.module.css | 12 ++ .../src/client/ThreadList.module.css | 86 +++++++++--- .../client-ui-forge/src/client/ThreadList.tsx | 126 +++++++++++------- .../client-ui-forge/src/client/locales.ts | 40 +++--- .../src/client/review-threads.ts | 29 +--- .../tests/changes-tab.client.spec.tsx | 56 ++++++-- .../tests/review-thread-cards.client.spec.tsx | 20 +-- .../tests/review-threads.client.spec.ts | 27 +--- 18 files changed, 346 insertions(+), 202 deletions(-) diff --git a/apps/web/tests/expected/forge-panel/review-thread-list.expected.md b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md index 0a2d0acbd7..bdfb2dc803 100644 --- a/apps/web/tests/expected/forge-panel/review-thread-list.expected.md +++ b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md @@ -1,17 +1,15 @@ - complementary "讨论列表,共 5 个": - text: 讨论 5 - button "关闭讨论列表" - - group "筛选讨论": - - button "未解决 4" - - button "已过期 1" - - button "已解决 1" - - button "需要我回复 0" - - heading "panel.md" [level=3] + - radiogroup "筛选讨论": + - radio "未解决 4" + - radio "已解决 1" + - radio "全部 5" [checked] + - heading "docs/panel.md" [level=3] - button "文件级讨论 Add a screenshot." - - heading "panel.ts" [level=3] + - heading "src/panel.ts" [level=3] - button "R2 · 1 条回复 Why two?" - button "L15 The old fifteen was a number." - button "R15 Settled. 已解决" - button "原在 R9 · 在 fffffff 上 This line moved. 已过期" - - text: github.com 上共 5 个讨论 · - button "在 GitHub 中查看" diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 1b0c0bc8fb..e8804e0253 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -1901,17 +1901,24 @@ it.skipIf(MODE === 'record')('overlays the review threads of a change request an await view.getByRole('button', { name: '讨论列表,共 5 个', exact: true }).click() const list = view.getByRole('complementary', { name: '讨论列表,共 5 个' }) - await expect.poll(() => list.getByRole('button', { pressed: true }).allTextContents()).toEqual(['未解决 4']) + await expect.poll(() => list.getByRole('radio', { checked: true }).allTextContents()).toEqual(['未解决4']) await expect.poll(() => list.locator('[data-forge-thread-item]').evaluateAll(items => items.map(item => item.getAttribute('data-forge-thread-item')))) .toEqual(['whole-file', 'on-line', 'old-side', 'outdated']) - await list.getByRole('button', { name: '已解决 1', exact: true }).click() + await list.getByRole('radio', { name: /^已解决/ }).click() await list.locator('[data-forge-thread-item="resolved"]').click() - // Opening a resolved thread unfolds it inside its file. + // Opening a resolved thread unfolds it inside its file, and the docked list stays. await panel.locator('[data-forge-thread="resolved"]').getByText('Settled.', { exact: true }).waitFor() - await list.getByRole('button', { name: '已解决 1', exact: true }).click() - await list.getByRole('button', { name: '需要我回复 0', exact: true }).waitFor() + await list.getByRole('radio', { name: /^全部/ }).click() + await expect.poll(() => list.locator('[data-forge-thread-item]').count()).toBe(5) await compareOrRefreshGolden(join(EXPECTED, 'review-thread-list.expected.md'), await captureStableAria(page, '[data-forge-thread-list]', scaffold.workspaceCwd), MODE) + // A view narrower than 480px gives the list the files' whole width and closes it before opening a thread. + await view.evaluate((element) => { element.style.width = '420px'; element.style.flex = 'none' }) + await expect.poll(() => list.evaluate(element => [getComputedStyle(element).position, element.getBoundingClientRect().width])) + .toEqual(['absolute', 420]) + await list.locator('[data-forge-thread-item="on-line"]').click() + await list.waitFor({ state: 'detached' }) + await view.evaluate((element) => { element.style.width = ''; element.style.flex = '' }) await onLine.getByRole('button', { name: '添加到输入框', exact: true }).click() await page.locator('[data-composer-input]').first().getByText('panel.ts R2', { exact: true }).first().waitFor() diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index a0392da274..b9dab01161 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: e9ecdf9670bd6324 - zh: 6f5e3027afb18a3e + en: 18b49590a4cda9ed + zh: 71e6de165fa4febc /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 78cab599dbd4aa57 zh: 87265844f34efbc5 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 190c23e7d0..230c028eb1 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -41,7 +41,7 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. - **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits. -- **Review threads** — A change request view overlays the review threads its code host read at the shown head. A thread on lines follows the row of its last line, on the side it was written on, or the collapsed unchanged lines that hold it; it shows the place it was written, each comment's avatar, author, time, and Markdown with raw HTML left out, and tags for the change request's author, bots, and comments still pending in the signed-in user's review. A resolved thread folds into one row that unfolds on request. Threads on a whole file open the file, and threads later commits made outdated gather after its lines in a section that shows the end of the hunk they were written on and offers View file at that commit, which reads the file at the commit they were written on with their lines marked. Chosen commits show only new-side threads whose lines number the last chosen commit. The file tree and each file header count unresolved threads; the count in a header, like the Threads toolbar button, opens the thread list, which groups the threads by file in file tree order, filters Unresolved, Outdated, Resolved, or Awaiting my reply, which keeps unresolved threads the signed-in user wrote in where someone else wrote last, and opens each thread at its place, choosing all commits for one the chosen commits do not hold. The list names a truncated read. A thread offers only Add to input, which quotes the whole thread, and View on GitHub. Threads are read again at each status while the view shows; a failed read keeps the last threads and offers Retry, and a provider that does not read reviews shows none. +- **Review threads** — A change request view overlays the review threads its code host read at the shown head. A thread on lines follows the row of its last line, on the side it was written on, or the collapsed unchanged lines that hold it; it shows the place it was written, each comment's avatar, author, time, and Markdown with raw HTML left out, and tags for the change request's author, bots, and comments still pending in the signed-in user's review. A resolved thread folds into one row that unfolds on request. Threads on a whole file open the file, and threads later commits made outdated gather after its lines in a section that shows the end of the hunk they were written on and offers View file at that commit, which reads the file at the commit they were written on with their lines marked. Chosen commits show only new-side threads whose lines number the last chosen commit. The file tree and each file header count unresolved threads; the count in a header, like the Threads toolbar button, opens the thread list, which groups the threads by file in file tree order, shows the Unresolved, Resolved, or All threads, Unresolved first, and opens each thread at its place, choosing all commits for one the chosen commits do not hold. The list docks beside the files in a view at least 1,040px wide, floats over them in a narrower one and closes before it opens a thread there, and takes their whole width below 480px. The list names a truncated read. A thread offers only Add to input, which quotes the whole thread, and View on GitHub. Threads are read again at each status while the view shows; a failed read keeps the last threads and offers Retry, and a provider that does not read reviews shows none. The checks row initially shows the polling preview. While it is incomplete, the card uses the host’s aggregate outcome without a failure count and labels the loaded check count as partial. Opening the list loads the remaining pages for that PR through `changeRequestChecks`; its deadline is independent of polling. A failed read keeps the preview and offers Retry. Closing the list, changing the head, or unmounting cancels the read; late results are ignored. Reopening reads current details. The Remote requires a visible Session binding and rejects a response for a different head commit. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index f7ced4299b..a3ce630d6a 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" - **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 - **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在;同一 Session 的多个标签页共用这些标记:在一个标签页中标记,其他标签页随之更新,且不会覆盖它们的标记。只改文件模式(例如 `chmod +x`)也算新改动。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交,读取一次后保留到 head、本地分支或基准变化或刷新为止;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交,这些提交必须依次是后一个的第一父提交:离开这条链的范围(例如跨越已合并分支的范围)会让菜单保持打开并说明原因;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。 -- **评审讨论**——变更请求视图会叠加代码托管平台在所显示 head 处读到的评审讨论。行级讨论显示在它最后一行所在的行之后,位于写下时所在的那一侧;若该行折叠在未变更的行中,则显示在折叠行之后。讨论标明写下时的位置,每条评论显示头像、作者、时间和 Markdown 正文(不显示原始 HTML),并用标签标出变更请求作者、机器人账号,以及当前登录用户尚未提交的评审中的评论。已解决的讨论折叠成一行,可以展开。针对整个文件的讨论显示在文件开头;被后续提交改到而过期的讨论集中在文件的行之后,显示写下时那段 hunk 的末尾,并提供“查看当时的文件”,读取写下讨论时那个提交上的文件并标出相应的行。选择了部分提交时,只显示行号对应最后一个所选提交的新版本侧讨论。文件树和每个文件标题都显示未解决讨论数;点击文件标题上的计数或工具栏的“讨论”按钮会打开讨论列表。列表按文件树顺序分组,可按未解决、已过期、已解决和“需要我回复”筛选(“需要我回复”保留当前登录用户参与过、且最后一条评论来自他人的未解决讨论),点击讨论会跳到它的位置;若所选提交中没有该讨论,会先切换到全部提交。读取被截断时,列表会注明。讨论上只有“添加到输入框”(把整个讨论作为引用插入)和“在 GitHub 中查看”两个操作。视图显示期间,每收到一次状态就重新读取讨论;读取失败时保留上次的讨论并提供重试,提供方不支持读取讨论时不显示讨论。 +- **评审讨论**——变更请求视图会叠加代码托管平台在所显示 head 处读到的评审讨论。行级讨论显示在它最后一行所在的行之后,位于写下时所在的那一侧;若该行折叠在未变更的行中,则显示在折叠行之后。讨论标明写下时的位置,每条评论显示头像、作者、时间和 Markdown 正文(不显示原始 HTML),并用标签标出变更请求作者、机器人账号,以及当前登录用户尚未提交的评审中的评论。已解决的讨论折叠成一行,可以展开。针对整个文件的讨论显示在文件开头;被后续提交改到而过期的讨论集中在文件的行之后,显示写下时那段 hunk 的末尾,并提供“查看当时的文件”,读取写下讨论时那个提交上的文件并标出相应的行。选择了部分提交时,只显示行号对应最后一个所选提交的新版本侧讨论。文件树和每个文件标题都显示未解决讨论数;点击文件标题上的计数或工具栏的“讨论”按钮会打开讨论列表。列表按文件树顺序分组,可显示未解决、已解决或全部讨论,默认显示未解决的讨论;点击讨论会跳到它的位置,若所选提交中没有该讨论,会先切换到全部提交。视图宽度不小于 1,040px 时列表停靠在文件旁;更窄时浮在文件之上,并在跳到讨论前先关闭;窄于 480px 时占满文件区的宽度。读取被截断时,列表会注明。讨论上只有“添加到输入框”(把整个讨论作为引用插入)和“在 GitHub 中查看”两个操作。视图显示期间,每收到一次状态就重新读取讨论;读取失败时保留上次的讨论并提供重试,提供方不支持读取讨论时不显示讨论。 检查行先显示轮询预览。不完整时,卡片使用托管平台的总体状态而不显示失败总数,并注明已加载的检查数量尚不完整。打开列表后,经 `changeRequestChecks` 单独读取该 PR 的后续页,时限独立于轮询。读取失败时保留预览并提供重试。关闭列表、head 变化或组件卸载会取消读取,忽略迟到结果;重新打开会读取当前详情。Remote 要求 URL 是该 Session 可见的绑定,并拒绝 head 提交不一致的响应。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index 23452bbc00..8d5c5b2a1d 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -840,6 +840,14 @@ .threadCount:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 20%, transparent); } } +/* A view narrower than 480px shows the count alone. */ +.threadCountNumber { display: none; } + +@container forge-changes (max-width: 479px) { + .threadCountText { display: none; } + .threadCountNumber { display: inline; } +} + /* Opens the thread list; pressed while the list shows. */ .threadsToggle { display: inline-flex; diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index d550841145..ec7fc28444 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -581,10 +581,13 @@ export function ChangesTab({ const placed = usePlacedThreads(review, data?.kind === 'change' ? { head: prHead ?? '', end: data.headSha, commits: data.commits } : null) const [threadList, setThreadList] = useState<{ readonly open: boolean - readonly filter: ThreadFilter | null + readonly filter: ThreadFilter /** File whose group the list scrolls to, or null. */ readonly file: string | null - }>({ open: false, filter: 'unresolved', file: null }) + /** Whether the reader just opened the list and it has not taken the focus yet. */ + readonly claim: boolean + }>({ open: false, filter: 'unresolved', file: null, claim: false }) + const threadsToggle = useRef(null) const [focus, setFocus] = useState(null) const allFiles = useMemo(() => filesInTreeOrder(changedFileTree(data?.files ?? [])), [data]) const files = useMemo(() => allFiles.filter(file => shownBy(file, lens, hideTests)), [allFiles, lens, hideTests]) @@ -720,7 +723,13 @@ export function ChangesTab({ const threadsOf = (path: string): FileThreads | undefined => { const group = placed.files.get(path) return threadActions === null || group === undefined ? undefined - : { ...group, actions: threadActions, openList: () => { setThreadList(value => ({ ...value, open: true, file: path })) } } + : { ...group, actions: threadActions, + openList: () => { setThreadList(value => ({ ...value, open: true, file: path, claim: true })) } } + } + /** Close the thread list at the reader's request and return the focus to its toggle. */ + const closeThreadList = (): void => { + setThreadList(value => ({ ...value, open: false })) + threadsToggle.current?.focus() } /** Show a thread from the list: all commits for a thread the chosen ones do not hold, then its file, unfolded and centered. */ const openThread = ({ thread, place }: PlacedThread): void => { @@ -948,9 +957,9 @@ export function ChangesTab({ } {review !== null && review.threads.length > 0 && ( - )} @@ -1015,10 +1024,14 @@ export function ChangesTab({ {body} {allFiles.length > 0 && } {review !== null && data?.kind === 'change' && threadList.open && ( - file.path)} viewer={review.viewer} - truncated={review.truncated} host={new URL(data.url).host} url={data.url} + file.path)} truncated={review.truncated} url={data.url} filter={threadList.filter} choose={(filter) => { setThreadList(value => ({ ...value, filter })) }} file={threadList.file} - open={openThread} openLink={openLink} close={() => { setThreadList(value => ({ ...value, open: false })) }} t={t} /> + claim={threadList.claim} claimed={() => { setThreadList(value => ({ ...value, claim: false })) }} + open={(entry, covering) => { + if (covering) setThreadList(value => ({ ...value, open: false })) + openThread(entry) + }} + openLink={openLink} close={closeThreadList} t={t} /> )}
diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index 0e7c561125..97478379ef 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -501,7 +501,10 @@ export function DiffFile(props: DiffFileProps): ReactNode { {viewed === 'changed' && {t('viewed.changed')}} {threads !== undefined && threads.unresolved > 0 && ( + onClick={threads.openList}> + {t('thread.unresolvedCount', { count: threads.unresolved })} + {threads.unresolved} + )} {badge !== '' && {badge}} diff --git a/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css b/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css index a08dc8e88e..655cf83171 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css @@ -1,6 +1,7 @@ -/* Review controls of the changes toolbar. The lens draws the segmented track and raised indicator of the shared - segmented control at the toolbar's 28px control height and 12px text, with each segment's file count after its label. */ -.lens { +/* Review controls of the changes toolbar. The segmented radio group of the lens and the thread list's filter draws the + track and raised indicator of the shared segmented control at the toolbar's 28px control height and 12px text, with + each segment's count after its label. */ +.segmented { box-sizing: border-box; display: grid; grid-auto-columns: minmax(0, 1fr); diff --git a/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx b/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx index 735c0148c4..b6765c158a 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx @@ -1,7 +1,7 @@ /** * Review controls of the changes toolbar: the lens that keeps the files of one review layer, as a full-width * radio group above the file tree or as a menu where no tree shows; the switch that hides test files; and the - * share of shown files the reader marked viewed. + * share of shown files the reader marked viewed. The thread list shares the segmented radio group. */ import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react' @@ -33,17 +33,35 @@ export interface LensProps extends PropsLocale { readonly choose: (lens: ChangesLens) => void } +/** One segment of a {@link SegmentedChoice}. */ +export interface Segment { + /** Value the segment chooses. */ + readonly value: Value + /** Localized label. */ + readonly label: string + /** Count shown after the label, or null for none. */ + readonly count: number | null + /** Localized hover text of a disabled segment, or null for an enabled one. */ + readonly disabledTip: string | null +} + /** - * Render the lens as a radio group whose segments share its width; arrow keys, Home, and End move between enabled lenses. - * @param props - the lens, its counts, whether test files hide, the choose action, and copy. + * Render a radio group whose segments share its width, each with a count after its label; arrow keys, Home, and End + * move between enabled segments. + * @param props - the segments, the chosen value, the choose action, and the group's localized accessible name. * @returns the radio group. */ -export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): ReactNode { +export function SegmentedChoice({ segments, value, choose, label }: { + readonly segments: readonly Segment[] + readonly value: Value + readonly choose: (value: Value) => void + readonly label: string +}): ReactNode { const group = useRef(null) - const enabled = LENSES.filter(item => item !== 'test' || !hideTests) + const enabled = segments.filter(segment => segment.disabledTip === null).map(segment => segment.value) const move = (event: KeyboardEvent): void => { - const at = enabled.indexOf(lens) - let next: ChangesLens | undefined + const at = enabled.indexOf(value) + let next: Value | undefined switch (event.key) { case 'ArrowLeft': case 'ArrowUp': next = enabled[(at + enabled.length - 1) % enabled.length]; break case 'ArrowRight': case 'ArrowDown': next = enabled[(at + 1) % enabled.length]; break @@ -53,28 +71,40 @@ export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): } event.preventDefault() event.stopPropagation() - /* v8 ignore next -- at most the test lens is disabled, so every key lands on an enabled lens */ + /* v8 ignore next -- every caller keeps a segment enabled, so every key lands on one */ if (next === undefined) return choose(next) - group.current?.querySelector(`[data-forge-lens="${next}"]`)?.focus() + group.current?.querySelector(`[data-segment="${next}"]`)?.focus() } return ( -
- {LENSES.map((item) => { - const disabled = item === 'test' && hideTests +
+ {segments.map((segment) => { const button = ( - ) - return disabled ? {button} : button + return segment.disabledTip === null ? button : {button} })}
) } +/** + * Render the lens as a radio group whose segments share its width. + * @param props - the lens, its counts, whether test files hide, the choose action, and copy. + * @returns the radio group. + */ +export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): ReactNode { + // A disabled lens shows no count, since hidden test files are not counted. + return item === 'test' && hideTests + ? { value: item, label: t(LENS_LABEL[item]), count: null, disabledTip: t('lens.testHidden') } + : { value: item, label: t(LENS_LABEL[item]), count: counts[item], disabledTip: null })} /> +} + /** * Render the lens as a menu button, for a toolbar without the file tree beside it. * @param props - the lens, its counts, whether test files hide, the choose action, copy, and whether the owning tab is visible. diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css index 00ed916016..b84b5a8428 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css @@ -217,3 +217,15 @@ font: var(--dsw-font-xs-13); font-family: Inter, var(--dsw-font-family); } + +/* A changes view narrower than 480px gives the code more width; the query names no container, since CSS Modules rename + a container name per file, and the nearest inline-size container is the changes view. */ +@container (max-width: 479px) { + .card, + .folded, + .card[data-split][data-side="R"], + .folded[data-split][data-side="R"] { + width: calc(100% - 12px); + margin: 6px; + } +} diff --git a/packages/experimental/client-ui-forge/src/client/ThreadList.module.css b/packages/experimental/client-ui-forge/src/client/ThreadList.module.css index c38bf4b549..2e91c9681f 100644 --- a/packages/experimental/client-ui-forge/src/client/ThreadList.module.css +++ b/packages/experimental/client-ui-forge/src/client/ThreadList.module.css @@ -1,4 +1,9 @@ -/* The thread list docks at the end of the changes body on wide panes and floats over the files on narrow ones. */ +/* The thread list docks at the end of the changes body in a view at least 1040px wide, which leaves the code about + 480px beside the file tree and the list; it floats over the files with a scrim behind it in a narrower view, and + takes the files' whole width below 480px. The queries name no container, since CSS Modules rename a container name + per file; the nearest inline-size container is the changes view. */ +.scrim { display: none; } + .pane { display: flex; flex: none; @@ -13,16 +18,7 @@ font-family: Inter, var(--dsw-font-family); } -@container forge-changes (max-width: 759px) { - .pane { - position: absolute; - z-index: 3; - inset-block: 0; - inset-inline-end: 0; - width: min(320px, 100%); - box-shadow: var(--dsw-shadow-l2, 0 4px 16px rgb(0 0 0 / 12%)); - } -} +.pane:focus { outline: none; } .header { display: flex; @@ -34,6 +30,25 @@ .title { font-weight: 500; } +/* Takes the place of the title and the close button where the list covers the files' whole width. */ +.back { + display: none; + align-items: center; + gap: 2px; + height: 28px; + padding: 0 8px 0 4px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + cursor: pointer; +} + +@media (hover: hover) { + .back:hover { background: var(--dsw-alias-interactive-bg-hover); } +} + .total { padding: 0 6px; border-radius: var(--dsw-radius-xs); @@ -64,9 +79,6 @@ } .filters { - display: flex; - flex-wrap: wrap; - gap: 6px; padding: 0 12px 10px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } @@ -83,12 +95,9 @@ .path { margin: 0; padding: 8px 6px 4px; - overflow: hidden; - color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; + font-weight: 500; } .item { @@ -155,6 +164,9 @@ } .link { + display: inline-flex; + align-items: center; + gap: 2px; padding: 0; border: 0; background: transparent; @@ -166,3 +178,41 @@ @media (hover: hover) { .link:hover { text-decoration: underline; } } + +/* Above the sticky file headers (z-index 2). */ +@container (max-width: 1039px) { + .scrim { + position: absolute; + z-index: 3; + inset: 0; + display: block; + background: var(--dsw-alias-bg-mask-1); + } + + .pane { + position: absolute; + z-index: 3; + inset-block: 0; + inset-inline-end: 0; + width: min(340px, calc(100% - 40px)); + box-shadow: var(--dsw-elevation-panel); + } +} + +@container (max-width: 479px) { + .scrim { display: none; } + + .pane { + width: 100%; + border-inline-start: 0; + box-shadow: none; + } + + .header { padding-inline-start: 6px; } + + .back { display: inline-flex; } + + .title, + .total, + .close { display: none; } +} diff --git a/packages/experimental/client-ui-forge/src/client/ThreadList.tsx b/packages/experimental/client-ui-forge/src/client/ThreadList.tsx index 66a968c788..cdebe62f5b 100644 --- a/packages/experimental/client-ui-forge/src/client/ThreadList.tsx +++ b/packages/experimental/client-ui-forge/src/client/ThreadList.tsx @@ -1,15 +1,18 @@ /** * The thread list of a change request beside its changes: every thread grouped by file in file tree order, narrowed - * by one filter, each opening the place it shows at. + * by whether it is resolved, each opening the place it shows at. A wide changes view docks the list beside the files; + * a narrower one floats it over them, and the narrowest gives it their whole width. */ -import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react' -import { IconCloseOutlineRegular, Pill, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { useLayoutEffect, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react' +import { IconChevronLeftOutlineRegular, IconCloseOutlineRegular, IconRightUpOutlineRegular, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' import { assertNever } from '@deepseek-ai/dsh-util-values' +import { FileLabel } from './FileLabel.tsx' +import { SegmentedChoice } from './ReviewControls.tsx' import { Avatar } from './ReviewThreads.tsx' -import { anchorRange, awaitsViewer, passesFilter, THREAD_FILTERS, type ThreadFilter, type ThreadPlace } from './review-threads.ts' +import { anchorRange, passesFilter, THREAD_FILTERS, type ThreadFilter, type ThreadPlace } from './review-threads.ts' import type { NS } from './locales.ts' import type { PlacedThread } from './thread-state.ts' import css from './ThreadList.module.css' @@ -22,25 +25,25 @@ export interface ThreadListProps extends PropsLocale { readonly entries: readonly PlacedThread[] /** Changed file paths in file tree order; threads on other paths follow them. */ readonly order: readonly string[] - /** Login of the signed-in user. */ - readonly viewer: string | null /** Whether the host has more threads than were read. */ readonly truncated: boolean - /** Host name the threads were read from, such as `github.com`. */ - readonly host: string /** Web page of the change request. */ readonly url: string - /** The active filter, or null for every thread. */ - readonly filter: ThreadFilter | null - /** Choose a filter, or null for every thread. */ - readonly choose: (filter: ThreadFilter | null) => void + /** The active filter. */ + readonly filter: ThreadFilter + /** Choose a filter. */ + readonly choose: (filter: ThreadFilter) => void /** File whose group the list scrolls to when it changes, or null. */ readonly file: string | null - /** Open the place of a thread. */ - readonly open: (entry: PlacedThread) => void + /** Whether the reader just opened the list, so a list that covers the files takes the focus. */ + readonly claim: boolean + /** Report that the list handled `claim`. */ + readonly claimed: () => void + /** Open the place of a thread; `covering` says the list covers the files, so the owner closes it first. */ + readonly open: (entry: PlacedThread, covering: boolean) => void /** Open a URL outside the app. */ readonly openLink: (url: string) => void - /** Close the list. */ + /** Close the list at the reader's request. */ readonly close: () => void } @@ -68,6 +71,11 @@ function meta(thread: ForgeReviewThread, place: ThreadPlace, t: Translate): stri return parts.join(' · ') } +/** Whether the list floats over the files or takes their width, which its narrow-view styles decide. */ +function covers(pane: HTMLElement | null): pane is HTMLElement { + return pane !== null && getComputedStyle(pane).position === 'absolute' +} + /** * The first comment as one line of plain text: without raw HTML, strong, strikethrough, and code marks, quote or * heading marks, and link targets. @@ -77,9 +85,8 @@ function plainExcerpt(body: string): string { .replace(/\*\*|__|~~|`/g, '').replace(/\s+/g, ' ').trim() } -function Item({ entry, viewer, open, t }: { +function Item({ entry, open, t }: { readonly entry: PlacedThread - readonly viewer: string | null readonly open: (entry: PlacedThread) => void readonly t: Translate }): ReactNode { @@ -94,7 +101,6 @@ function Item({ entry, viewer, open, t }: { {place.kind === 'outdated' && {t('thread.outdatedTag')}} {place.kind === 'elsewhere' && {t('thread.elsewhereTag')}} {thread.resolved && {t('thread.filter.resolved')}} - {awaitsViewer(thread, viewer) && {t('thread.filter.mine')}} {first?.author?.bot === true && {t('thread.bot')}} @@ -104,25 +110,26 @@ function Item({ entry, viewer, open, t }: { /** * Render the thread list. * @param props - threads, their places, the file order, the filter, and the list's actions. - * @returns the list pane. + * @returns the list pane and the scrim behind it where it floats over the files. */ export function ThreadList(props: ThreadListProps): ReactNode { - const { entries, order, viewer, filter, t } = props + const { entries, order, filter, t } = props const threads = useMemo(() => entries.map(entry => entry.thread), [entries]) const counts = useMemo(() => Object.fromEntries(THREAD_FILTERS.map(item => [item, - threads.filter(thread => passesFilter(thread, item, viewer)).length])) as Record, [threads, viewer]) + threads.filter(thread => passesFilter(thread, item)).length])) as Record, [threads]) const groups = useMemo(() => { const position = new Map(order.map((path, index) => [path, index])) const byPath = new Map() for (const entry of entries) { - if (!passesFilter(entry.thread, filter, viewer)) continue + if (!passesFilter(entry.thread, filter)) continue const list = byPath.get(entry.thread.path) if (list === undefined) byPath.set(entry.thread.path, [entry]) else list.push(entry) } return [...byPath].sort(([a], [b]) => (position.get(a) ?? order.length) - (position.get(b) ?? order.length) || a.localeCompare(b)) .map(([path, list]) => [path, list.toSorted((a, b) => rank(a.place) - rank(b.place))] as const) - }, [entries, order, filter, viewer]) + }, [entries, order, filter]) + const pane = useRef(null) const rows = useRef(null) useLayoutEffect(() => { const container = rows.current @@ -132,32 +139,49 @@ export function ThreadList(props: ThreadListProps): ReactNode { if (group.dataset.forgeThreadGroup === props.file) group.scrollIntoView({ block: 'start' }) } }, [props.file]) - return + useLayoutEffect(() => { + if (!props.claim) return + props.claimed() + if (covers(pane.current)) pane.current.focus({ preventScroll: true }) + }, [props.claim]) + const escape = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + event.preventDefault() + event.stopPropagation() + props.close() + } + const open = (entry: PlacedThread): void => { props.open(entry, covers(pane.current)) } + return <> + -
+
{threads.length === 0 ?
{t('thread.empty')}
: groups.length === 0 ?
{t('thread.none')}
: groups.map(([path, list]) =>
diff --git a/packages/experimental/client-ui-forge/src/client/locales.ts b/packages/experimental/client-ui-forge/src/client/locales.ts index 9ba50586b3..5a95837897 100644 --- a/packages/experimental/client-ui-forge/src/client/locales.ts +++ b/packages/experimental/client-ui-forge/src/client/locales.ts @@ -339,7 +339,8 @@ export const zh = { 'thread.originalFailed': '无法读取当时的文件:{message}', 'thread.openOnHost': '在 GitHub 中查看', 'thread.unresolvedCount': '{count} 条未解决', - 'thread.unresolvedAria': '{name} 有 {count} 条未解决的讨论,打开讨论列表', + 'thread.unresolvedAria': '{name} 有 {count} 条未解决的讨论,跳到下一条', + 'thread.next': '跳到下一条未解决的讨论', 'thread.treeCount': '{count} 条未解决的讨论', 'thread.loadFailed': '无法读取 GitHub 讨论:{message}', 'thread.inputFailed': '输入框暂时无法添加这个讨论,请稍后重试', @@ -690,7 +691,8 @@ export const en: Record = { 'thread.originalFailed': 'Could not read the file at that commit: {message}', 'thread.openOnHost': 'View on GitHub', 'thread.unresolvedCount': '{count} unresolved', - 'thread.unresolvedAria': '{name} has {count} unresolved threads; open the thread list', + 'thread.unresolvedAria': '{name} has {count} unresolved threads; go to the next one', + 'thread.next': 'Go to the next unresolved thread', 'thread.treeCount': '{count} unresolved threads', 'thread.loadFailed': 'Could not read GitHub threads: {message}', 'thread.inputFailed': 'The input could not take this thread; try again later', @@ -1045,7 +1047,8 @@ export const ja: Record = { 'thread.originalFailed': '当時のファイルを読み込めません:{message}', 'thread.openOnHost': 'GitHub で表示', 'thread.unresolvedCount': '未解決 {count} 件', - 'thread.unresolvedAria': '{name} に未解決のスレッドが {count} 件あります。スレッド一覧を開く', + 'thread.unresolvedAria': '{name} に未解決のスレッドが {count} 件あります。次のスレッドへ移動', + 'thread.next': '次の未解決のスレッドへ移動', 'thread.treeCount': '未解決のスレッド {count} 件', 'thread.loadFailed': 'GitHub のスレッドを読み込めません:{message}', 'thread.inputFailed': '入力欄にこのスレッドを追加できません。しばらくしてから再試行してください', @@ -1393,7 +1396,8 @@ export const es: Record = { 'thread.originalFailed': 'No se pudo leer el archivo de ese commit: {message}', 'thread.openOnHost': 'Ver en GitHub', 'thread.unresolvedCount': '{count} sin resolver', - 'thread.unresolvedAria': '{name} tiene {count} hilos sin resolver; abrir la lista de hilos', + 'thread.unresolvedAria': '{name} tiene {count} hilos sin resolver; ir al siguiente', + 'thread.next': 'Ir al siguiente hilo sin resolver', 'thread.treeCount': '{count} hilos sin resolver', 'thread.loadFailed': 'No se pudieron leer los hilos de GitHub: {message}', 'thread.inputFailed': 'El campo de entrada no pudo recibir este hilo; inténtalo más tarde', @@ -1741,7 +1745,8 @@ export const fr: Record = { 'thread.originalFailed': 'Impossible de lire le fichier à ce commit : {message}', 'thread.openOnHost': 'Voir sur GitHub', 'thread.unresolvedCount': '{count} non résolus', - 'thread.unresolvedAria': '{name} a {count} fils non résolus ; ouvrir la liste des fils', + 'thread.unresolvedAria': '{name} a {count} fils non résolus ; aller au suivant', + 'thread.next': 'Aller au fil non résolu suivant', 'thread.treeCount': '{count} fils non résolus', 'thread.loadFailed': 'Impossible de lire les fils GitHub : {message}', 'thread.inputFailed': 'Le champ de saisie n’a pas pu recevoir ce fil ; réessayez plus tard', @@ -2089,7 +2094,8 @@ export const de: Record = { 'thread.originalFailed': 'Die Datei in diesem Commit konnte nicht gelesen werden: {message}', 'thread.openOnHost': 'Auf GitHub ansehen', 'thread.unresolvedCount': '{count} ungelöst', - 'thread.unresolvedAria': '{name} hat {count} ungelöste Threads; Threadliste öffnen', + 'thread.unresolvedAria': '{name} hat {count} ungelöste Threads; zum nächsten springen', + 'thread.next': 'Zum nächsten ungelösten Thread springen', 'thread.treeCount': '{count} ungelöste Threads', 'thread.loadFailed': 'GitHub-Threads konnten nicht gelesen werden: {message}', 'thread.inputFailed': 'Das Eingabefeld konnte diesen Thread nicht aufnehmen; später erneut versuchen', @@ -2437,7 +2443,8 @@ export const ru: Record = { 'thread.originalFailed': 'Не удалось прочитать файл в том коммите: {message}', 'thread.openOnHost': 'Открыть на GitHub', 'thread.unresolvedCount': 'Нерешённых: {count}', - 'thread.unresolvedAria': 'В {name} нерешённых обсуждений: {count}. Открыть список обсуждений', + 'thread.unresolvedAria': 'В {name} нерешённых обсуждений: {count}. Перейти к следующему', + 'thread.next': 'Перейти к следующему нерешённому обсуждению', 'thread.treeCount': 'Нерешённых обсуждений: {count}', 'thread.loadFailed': 'Не удалось прочитать обсуждения GitHub: {message}', 'thread.inputFailed': 'Не удалось добавить обсуждение в поле ввода; повторите попытку позже', @@ -2785,7 +2792,8 @@ export const pt: Record = { 'thread.originalFailed': 'Não foi possível ler o arquivo naquele commit: {message}', 'thread.openOnHost': 'Ver no GitHub', 'thread.unresolvedCount': '{count} não resolvidas', - 'thread.unresolvedAria': '{name} tem {count} discussões não resolvidas; abrir a lista de discussões', + 'thread.unresolvedAria': '{name} tem {count} discussões não resolvidas; ir para a próxima', + 'thread.next': 'Ir para a próxima discussão não resolvida', 'thread.treeCount': '{count} discussões não resolvidas', 'thread.loadFailed': 'Não foi possível ler as discussões do GitHub: {message}', 'thread.inputFailed': 'O campo de entrada não pôde receber esta discussão; tente novamente mais tarde', diff --git a/packages/experimental/client-ui-forge/src/client/review-threads.ts b/packages/experimental/client-ui-forge/src/client/review-threads.ts index b560da934c..84091aae7f 100644 --- a/packages/experimental/client-ui-forge/src/client/review-threads.ts +++ b/packages/experimental/client-ui-forge/src/client/review-threads.ts @@ -1,12 +1,13 @@ /** - * Where the changes view shows each review thread of a change request, which threads each filter of the thread list - * keeps, and the composer quote of a whole thread. + * Where the changes view shows each review thread of a change request, the order a file shows them in, which threads + * each filter of the thread list keeps, and the composer quote of a whole thread. */ import type { QuoteSummary } from '@deepseek-ai/dsh-client-ui-primitives' import type { ForgeAnchor, ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' import { assertNever } from '@deepseek-ai/dsh-util-values' -import type { ForgeCommitRange, ForgeDiffFile } from '../types.ts' +import type { ForgeCommitRange, ForgeDiffFile, ForgeDiffHunk } from '../types.ts' +import { hunkRows } from './diff-rows.ts' /** The changes a view shows, which decides where a thread can sit. */ export interface ThreadView { @@ -65,6 +66,19 @@ export function anchorRange(side: 'L' | 'R', anchor: Pick(items: readonly Item[], file: ForgeDiffFile | undefined): Item[] { + const rank = ({ place }: Item): readonly [number, number, number] => { + switch (place.kind) { + case 'file': return [0, 0, 0] + case 'line': return [1, ...rowOf(file, place.side, place.endLine)] + case 'outdated': return [2, 0, 0] + case 'elsewhere': return [3, 0, 0] + /* v8 ignore next -- closed ThreadPlace union exhaustiveness guard */ + default: return assertNever(place, 'ThreadPlace') + } } - return file.hunks.length + const keyed = items.map(item => [rank(item), item] as const) + return keyed.toSorted(([a], [b]) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]).map(([, item]) => item) +} + +/** + * Where a line draws: twice the index of the collapsed lines that hold it, or twice a hunk's index plus one with the + * line's row in that hunk, so rows of one hunk keep their patch order across both sides. + */ +function rowOf(file: ForgeDiffFile | undefined, side: 'L' | 'R', line: number): readonly [number, number] { + if (file === undefined) return [0, line] + const spot = spotOf(file, side, line) + if (spot.kind === 'gap') return [2 * spot.index, line] + return [2 * spot.index + 1, hunkRows(spot.hunk).findIndex(row => (side === 'L' ? row.old : row.new) === line)] } /** diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index c7e4c4e00a..1448f60b4e 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -1693,7 +1693,7 @@ describe('ChangesTab review threads', () => { expect(card(m, 'in-gap')?.previousElementSibling?.getAttribute('data-forge-diff-gap')).toBe('1') expect(card(m, 'resolved')?.textContent).toContain('Resolved thread · reviewer') // The count alone shows below 480px. - expect([...within(app).getByRole('button', { name: 'app.ts has 4 unresolved threads; open the thread list' }).children] + expect([...within(app).getByRole('button', { name: 'app.ts has 4 unresolved threads; go to the next one' }).children] .map(text => text.textContent)).toEqual(['4 unresolved', '4']) expect(app.querySelector('[data-forge-outdated-threads]')?.textContent).toContain('1 outdated threads') fireEvent.click(within(card(m, 'outdated')!).getByRole('button', { name: en['thread.viewOriginal'] })) @@ -1746,9 +1746,7 @@ describe('ChangesTab review threads', () => { fireEvent.keyDown(list, { key: 'Escape' }) expect(m.view.queryByRole('complementary')).toBeNull() expect(document.activeElement).toBe(toggle) - scrolled.mockClear() - fireEvent.click(within(m.section('src/app.ts')).getByRole('button', { name: /^app\.ts has 4 unresolved threads/ })) - expect(scrolled.mock.contexts).toContain(m.view.container.querySelector('[data-forge-thread-group="src/app.ts"]')) + fireEvent.click(toggle) fireEvent.click(within(m.view.getByRole('complementary')).getByRole('button', { name: en['thread.close'] })) expect(m.view.queryByRole('complementary')).toBeNull() fireEvent.click(toggle) @@ -1759,6 +1757,21 @@ describe('ChangesTab review threads', () => { expect(m.view.queryByRole('complementary')).toBeNull() }) + it('steps through a file\'s unresolved threads in display order from the count in its header', async () => { + const m = mount({ source, setup: reviewed() }) + await settle() + const shown: (string | undefined)[] = [] + for (let step = 0; step < 5; step += 1) { + scrolled.mockClear() + fireEvent.click(within(m.section('src/app.ts')).getByRole('button', { name: 'app.ts has 4 unresolved threads; go to the next one' })) + await settle() + shown.push((scrolled.mock.contexts as HTMLElement[]).find(element => element.dataset.forgeThread !== undefined)?.dataset.forgeThread) + } + // R4 in the first hunk, R8 in the unchanged lines after it, L11 in the second hunk, then the outdated thread, then R4 again. + expect(shown).toEqual(['on-row', 'in-gap', 'old-side', 'outdated', 'on-row']) + expect(m.view.queryByRole('complementary')).toBeNull() + }) + it('takes the focus where the thread list covers the files, and closes it before opening a thread', async () => { // The narrow-view styles float the list; jsdom computes this rule in their place. const style = document.createElement('style') diff --git a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx index 172bf55921..0e27ce88c4 100644 --- a/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/review-thread-cards.client.spec.tsx @@ -8,7 +8,7 @@ import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol import { en } from '../src/client/locales.ts' import { Avatar, OutdatedThreads, ThreadCard, type ThreadActions } from '../src/client/ReviewThreads.tsx' import { ThreadList, type ThreadListProps } from '../src/client/ThreadList.tsx' -import { reviewComment, reviewThread } from './thread-fixtures.client.ts' +import { diffFile, reviewComment, reviewThread } from './thread-fixtures.client.ts' const t = makeTranslate(en) const OLD = 'b'.repeat(40) @@ -157,7 +157,7 @@ describe('OutdatedThreads', () => { describe('ThreadList', () => { function list(overrides: Partial = {}) { const props: ThreadListProps = { - entries: [], order: [], truncated: false, url: 'https://github.com/octo/demo/pull/7', filter: 'all', file: null, claim: false, + entries: [], files: [], truncated: false, url: 'https://github.com/octo/demo/pull/7', filter: 'all', claim: false, claimed: vi.fn(), choose: vi.fn(), open: vi.fn(), openLink: vi.fn(), close: vi.fn(), t, ...overrides, } return { props, view: render() } @@ -199,15 +199,19 @@ describe('ThreadList', () => { expect(props.close).toHaveBeenCalledOnce() }) - it('groups threads by file in file order, scrolls to a file\'s group, and moves the filter with arrow keys', () => { - const entry = { thread: reviewThread({ path: 'b.ts' }), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const } - const other = { thread: reviewThread({ path: 'a.ts' }), place: { kind: 'line', side: 'R', startLine: 3, endLine: 3 } as const } + it('groups threads by file in file order, orders them by row, and moves the filter with arrow keys', () => { + const at = (id: string, path: string, side: 'L' | 'R', line: number) => ({ thread: reviewThread({ id: id as ForgeThreadId, path }), + place: { kind: 'line', side, startLine: line, endLine: line } as const }) const empty = { thread: reviewThread({ path: 'c.ts', comments: [] }), place: { kind: 'file' } as const } - const { view, props } = list({ entries: [empty, entry, other], order: ['b.ts'], filter: 'unresolved', file: 'a.ts' }) + // In b.ts the added R2 row follows the deleted L2 row, and L9 sits in the unchanged lines after the hunk. + const files = [diffFile('b.ts', [{ oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: [' a', '-b', '+B'] }])] + const { view, props } = list({ entries: [empty, at('late', 'b.ts', 'L', 9), at('added', 'b.ts', 'R', 2), at('other', 'a.ts', 'R', 3), + at('deleted', 'b.ts', 'L', 2)], files, filter: 'unresolved' }) expect([...view.container.querySelectorAll('[data-forge-thread-group]')].map(group => group.getAttribute('data-forge-thread-group'))) .toEqual(['b.ts', 'a.ts', 'c.ts']) + expect([...view.container.querySelectorAll('[data-forge-thread-group="b.ts"] [data-forge-thread-item]')].map(item => item.getAttribute('data-forge-thread-item'))) + .toEqual(['deleted', 'added', 'late']) expect(view.container.querySelector('[data-forge-thread-group="c.ts"] [class*="excerpt"]')?.textContent).toBe('') - expect(scrolled.mock.contexts).toEqual([view.container.querySelector('[data-forge-thread-group="a.ts"]')]) fireEvent.keyDown(view.getByRole('radio', { name: /^Unresolved/ }), { key: 'ArrowRight' }) expect(props.choose).toHaveBeenCalledWith('resolved') }) diff --git a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts index 1ac3c0120a..f2f5f6e644 100644 --- a/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/review-threads.client.spec.ts @@ -2,10 +2,9 @@ import { describe, expect, it } from 'vitest' import type { ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types' import { - anchorRange, formatThreadQuote, gapOf, hunkExcerpt, passesFilter, placeThread, threadQuoteSummary, THREAD_FILTERS, + anchorRange, formatThreadQuote, gapOf, inFileOrder, hunkExcerpt, passesFilter, placeThread, threadQuoteSummary, THREAD_FILTERS, } from '../src/client/review-threads.ts' -import type { ForgeDiffFile } from '../src/types.ts' -import { reviewComment, reviewThread } from './thread-fixtures.client.ts' +import { diffFile, reviewComment, reviewThread } from './thread-fixtures.client.ts' const HEAD = 'a'.repeat(40) const OLD = 'b'.repeat(40) @@ -44,14 +43,10 @@ it('names a side and its lines', () => { }) it('finds the collapsed unchanged lines that hold a line on either side', () => { - const file: ForgeDiffFile = { - path: 'a.ts', previousPath: null, status: 'modified', binary: false, oversized: false, untracked: false, additions: 2, deletions: 2, - digest: 'digest', layer: 'implementation', - hunks: [ - { oldStart: 5, oldLines: 3, newStart: 5, newLines: 3, lines: [' a', '-b', '+b', ' c'] }, - { oldStart: 20, oldLines: 2, newStart: 22, newLines: 2, lines: ['-x', '+x', ' y'] }, - ], - } + const file = diffFile('a.ts', [ + { oldStart: 5, oldLines: 3, newStart: 5, newLines: 3, lines: [' a', '-b', '+b', ' c'] }, + { oldStart: 20, oldLines: 2, newStart: 22, newLines: 2, lines: ['-x', '+x', ' y'] }, + ]) expect(gapOf(file, 'R', 2)).toBe(0) expect(gapOf(file, 'R', 6)).toBeUndefined() expect(gapOf(file, 'R', 10)).toBe(1) @@ -60,6 +55,23 @@ it('finds the collapsed unchanged lines that hold a line on either side', () => expect(gapOf(file, 'R', 40)).toBe(2) }) +it('orders a file\'s threads as its section shows them', () => { + const file = diffFile('a.ts', [ + { oldStart: 5, oldLines: 3, newStart: 5, newLines: 3, lines: [' a', '-b', '+b', ' c'] }, + { oldStart: 20, oldLines: 2, newStart: 22, newLines: 2, lines: ['-x', '+x', ' y'] }, + ]) + const line = (id: string, side: 'L' | 'R', at: number) => ({ id, place: { kind: 'line', side, startLine: at, endLine: at } as const }) + const items = [ + { id: 'away', place: { kind: 'elsewhere' } as const }, { id: 'gone', place: { kind: 'outdated' } as const }, + line('tail', 'R', 40), line('added', 'R', 6), line('second-hunk', 'L', 20), line('before', 'R', 2), line('deleted', 'L', 6), + { id: 'whole', place: { kind: 'file' } as const }, line('between', 'L', 10), + ] + expect(inFileOrder(items, file).map(item => item.id)) + .toEqual(['whole', 'before', 'deleted', 'added', 'between', 'second-hunk', 'tail', 'gone', 'away']) + // Without the file's diff, lines sort by their number. + expect(inFileOrder([line('b', 'R', 9), line('a', 'L', 2)], undefined).map(item => item.id)).toEqual(['a', 'b']) +}) + describe('thread filters', () => { it('keeps the threads each filter names', () => { const outdated = reviewThread({ current: null }) diff --git a/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts b/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts index fe08cb6a60..d4aa1d0f02 100644 --- a/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts +++ b/packages/experimental/client-ui-forge/tests/thread-fixtures.client.ts @@ -1,6 +1,7 @@ -/** Host review threads and comments shared by the browser-side thread tests. */ +/** Host review threads, comments, and diff files shared by the browser-side thread tests. */ import type { ForgeCommentId, ForgeReviewComment, ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' +import type { ForgeDiffFile, ForgeDiffHunk } from '../src/types.ts' let comments = 0 @@ -41,3 +42,14 @@ export function reviewThread(overrides: Partial = {}): ForgeR ...overrides, } } + +/** + * Build a modified text file with hunks. + * @param path - the file's path. + * @param hunks - its hunks. + * @returns the diff file. + */ +export function diffFile(path: string, hunks: readonly ForgeDiffHunk[]): ForgeDiffFile { + return { path, previousPath: null, status: 'modified', binary: false, oversized: false, untracked: false, additions: 1, deletions: 1, + digest: 'digest', layer: 'implementation', hunks } +} From 040c93f9b9384de5c012bfdf7a5774dd84a7551e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:09:06 +0800 Subject: [PATCH 57/81] fix(forge-panel): report a refused patch before a failed .gitattributes read Both reads still run in parallel, and a failed patch read still cancels the attributes read, but the result now waits for both: the patch's failure passes on first, so a too-large or stale patch is never hidden by an attributes failure that happened to end earlier. Also documents the estimated height of undrawn files. --- .../client-ui-forge/README.i18n.yaml | 8 ++--- .../experimental/client-ui-forge/README.md | 3 +- .../experimental/client-ui-forge/README.zh.md | 3 +- .../experimental/client-ui-forge/src/index.ts | 25 ++++++-------- .../tests/controller.host.spec.ts | 34 ++++++++++++++----- 5 files changed, 44 insertions(+), 29 deletions(-) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index d2e078a9fc..2cbf20d4c3 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: c7e1b7d87ccccd2e zh: 42d864b918a6175c /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: d0756f4b236e1e6f - zh: 9a02f6fa80118c5a + en: 0d8e666fc1cd71db + zh: 69f3d10283c2edfc /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b @@ -30,8 +30,8 @@ en: 2bc25a99e17789c4 zh: 4bec723fbf249d28 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: 8f8ed6efb2fb7872 - zh: 489730ecefad078b + en: 4124d453778daf7b + zh: 33cab3137381942e /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index bc116fbb1d..784a05bfa2 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -69,7 +69,7 @@ Restoration records `restoring` before mutation and `restored` only after succes Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. -`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, a patch together with its `.gitattributes`, where the first failure cancels the other read and waits for it to end, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. +`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, a patch together with its `.gitattributes`, where a failed patch read cancels the attributes read and both reads end before the result, the patch's failure passing on before the attributes failure, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. `changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service. @@ -140,6 +140,7 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Changes scopes** — deferred: a scope for the last turn. The Branch scope's Default needs a remote whose default branch HEAD shares history with, or a worktree the Session created; otherwise a base branch must be chosen. - **Very many branches** — the base branch menu reads every ref before it filters, so a search can reach any branch. Refs over `maxDiffBytes`, about 165,000 at the default, or a listing over `gitTimeoutMs`, leave the menu saying that branches could not be read; Default keeps working. - **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. +- **Undrawn files** — until the reader scrolls near an expanded file, it holds 22 px per drawn line, which leaves out side-by-side pairing, wrapped lines, unchanged-line rows, and review cards, so the scroll range shifts a little as files draw; its saved review comments also appear only once it draws. - **Find in page over long diffs** — an expanded file draws its lines only once the reader scrolls near it, so the browser's find in page does not match lines of files further down until they are drawn; a page-wide search of the diff needs a find control of the view's own. - **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them. - **Review layers** — the built-in rules cover common conventions only, so a repository records others in `.gitattributes`; a path cannot show Rust's inline `#[cfg(test)]` modules or which declarations a language exports, and the Interface layer keeps whole files, so finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 81f2bed6f0..e4154cc47b 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -69,7 +69,7 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 -Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,补丁与它的 `.gitattributes` 同时读取,先失败的一方会取消另一方并等它结束,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,补丁与它的 `.gitattributes` 同时读取,补丁读取失败时取消 `.gitattributes` 的读取,两者都结束后才给出结果,补丁的失败优先于 `.gitattributes` 的失败,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 `changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。 @@ -140,6 +140,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **变更范围**——待办:上一轮的范围。“分支”范围的“默认”需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree;否则必须选择基准分支。 - **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。 - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 +- **尚未绘制的文件**——读者滚动到展开的文件附近之前,它按每个绘制行 22 px 占位,没有计入左右对比的配对、自动换行、未变更行按钮和评审卡片,所以文件绘制时滚动范围会略有变化;它已保存的评审评论也要等它绘制后才显示。 - **长 diff 中的页内查找**——展开的文件要等读者滚动到附近才绘制各行,所以浏览器的页内查找在更靠后的文件绘制之前匹配不到它们的行;要在整个 diff 中查找,需要视图自己的查找控件。 - **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。 - **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。 diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index b6dc034eac..6b05219291 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -690,8 +690,9 @@ export class ForgePanelController extends TypertRemoteService { } /** - * Read a patch that ends at `commit` together with the root `.gitattributes` at that commit. The first failure - * cancels the other read and waits for it to end before it passes on. + * Read a patch that ends at `commit` together with the root `.gitattributes` at that commit. A failed patch read + * cancels the attributes read. Both reads end before the result: the patch's failure passes on first, and the + * attributes failure only after the patch was read, so the outcome does not depend on which read ends first. * @param ref - change request whose repository holds the commit. * @param commit - commit the patch ends at. * @param readPatch - reads the patch under the given cancellation. @@ -702,21 +703,17 @@ export class ForgePanelController extends TypertRemoteService { private async patchAt( ref: ChangeRequestRef, commit: string, readPatch: (signal: AbortSignal) => Promise, signal: AbortSignal, ): Promise<{ readonly patch: string; readonly attributes: string }> { - const sibling = new AbortController() - const operation = AbortSignal.any([signal, sibling.signal]) - const patch = readPatch(operation).then((result) => { + const unneeded = new AbortController() + const patch = readPatch(signal).then((result) => { if (typeof result !== 'string') throw readError(result) return result }) - const attributes = this.attributesAt(ref, commit, operation) - try { - const [text, rules] = await Promise.all([patch, attributes]) - return { patch: text, attributes: rules } - } catch (error) { - sibling.abort() - await Promise.allSettled([patch, attributes]) - throw error - } + const attributes = this.attributesAt(ref, commit, AbortSignal.any([signal, unneeded.signal])) + void patch.then(undefined, () => { unneeded.abort() }) + const [text, rules] = await Promise.allSettled([patch, attributes]) + if (text.status === 'rejected') throw text.reason + if (rules.status === 'rejected') throw rules.reason + return { patch: text.value, attributes: rules.value } } /** The root `.gitattributes` of a change request's repository at a commit, empty when the commit has none. */ diff --git a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts index 92b5a6bdbc..96257f8c29 100644 --- a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts @@ -728,26 +728,43 @@ describe('forgePanel change request diffs', () => { .rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'down' }) }) - it('reads the patch and the .gitattributes together and cancels the other read when one fails', async () => { + it('reads the patch and the .gitattributes together, and reports the patch\'s failure before the attributes failure', async () => { const { repo } = await project() const opened = await open(repo) shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) await reader(opened).until(state => polled(state, pullUrl(7))) const read = async (): Promise => await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal) const cancelled: string[] = [] - const held = (name: string) => async (operation: AbortSignal): Promise => { - await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) }) - cancelled.push(name) - throw operation.reason + // The patch read ends only after the attributes read has failed, so the attributes failure always comes first. + let attributesFailed = Promise.withResolvers() + const later = (value: T) => async (): Promise => { + await attributesFailed.promise + return value } // Failed reads are not kept, so each case reads again. - const diff = vi.spyOn(opened.provider, 'diff').mockImplementation(async (_ref, _head, operation) => await held('patch')(operation)) - const text = vi.spyOn(opened.provider, 'fileText').mockResolvedValue({ code: 'unavailable', message: 'attributes down' }) + const diff = vi.spyOn(opened.provider, 'diff').mockImplementation(later({ code: 'too-large', message: 'diff over 300 files' })) + const text = vi.spyOn(opened.provider, 'fileText').mockImplementation(async () => { + attributesFailed.resolve(undefined) + return { code: 'unavailable', message: 'attributes down' } + }) + await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-too-large', message: 'diff over 300 files' }) + attributesFailed = Promise.withResolvers() + diff.mockImplementation(later({ code: 'stale', message: 'head moved' })) + await expect(read()).rejects.toMatchObject({ code: 'forgePanel/stale-head', message: 'head moved' }) + attributesFailed = Promise.withResolvers() + diff.mockImplementation(later(PATCH)) await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'attributes down' }) + + // A failed patch read cancels the attributes read it no longer needs. diff.mockResolvedValue({ code: 'unavailable', message: 'patch down' }) - text.mockImplementation(async (_ref, _commit, _path, operation) => await held('attributes')(operation)) + text.mockImplementation(async (_ref, _commit, _path, operation) => { + await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) }) + cancelled.push('attributes') + throw operation.reason + }) await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'patch down' }) + expect(cancelled).toEqual(['attributes']) // The patch read finishes only once the attributes read has started, so reading them one after the other never ends. const attributesStarted = Promise.withResolvers() @@ -760,7 +777,6 @@ describe('forgePanel change request diffs', () => { return 'src/** linguist-generated\n' }) await expect(read()).resolves.toMatchObject({ files: [{ path: 'src/a.ts', layer: 'test' }] }) - expect(cancelled).toEqual(['patch', 'attributes']) }) it('lists the commits of a change request and reads consecutive ones from the first one\'s parent', async () => { From 508b078373310038042d7c0e2dbff7f0ffa297e2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:09:08 +0800 Subject: [PATCH 58/81] fix(forge-github): keep the status when GitHub reports no full base commit The base commit only gives the diff read a head start, so a missing or malformed baseRefOid now leaves baseSha out instead of failing the status. --- packages/experimental/forge-github/README.i18n.yaml | 4 ++-- packages/experimental/forge-github/README.md | 2 +- packages/experimental/forge-github/README.zh.md | 2 +- packages/experimental/forge-github/src/graphql.ts | 5 +++-- packages/experimental/forge-github/tests/graphql.spec.ts | 7 +++++++ 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index 9cee871506..ad2931d2cd 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: 7cc428526bce20c2 - zh: e58b72d47ad8da87 + en: 937728d83a92e0f0 + zh: ea0805e75f2f4da0 /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: en: f98e77171cba809f zh: 1b330cb342d69efe diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 2f7bbf6355..31ed39a9bc 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -46,7 +46,7 @@ Unfinished check runs and completed runs with stale, missing, or unrecognized co A pull request is `mergeReady` when it is open, not a draft, GitHub reports its merge state as `CLEAN`, `HAS_HOOKS`, or `UNSTABLE`, and the signed-in user has write, maintain, or admin permission. `mergeMethods` lists the squash, merge-commit, and rebase methods the repository allows, the user's default merge method first. Marking ready runs `gh pr ready --repo //`. Merging calls `PUT repos///pulls//merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`. -`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. Each status snapshot carries `baseRefOid` as `baseSha`; given it, `diff` starts this comparison while the GraphQL query confirms the head, and a stale head or another base commit cancels the early comparison and waits for it to end before `diff` returns. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. +`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos///compare/...` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. Each status snapshot carries `baseRefOid` as `baseSha`, and leaves it out when GitHub reports no full commit there; given it, `diff` starts this comparison while the GraphQL query confirms the head, and a stale head or another base commit cancels the early comparison and waits for it to end before `diff` returns. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos///contents/?ref=` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://///blob//`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`. Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 0851001833..d867f19592 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -46,7 +46,7 @@ kind: "package-reference" pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready --repo //`。合并经 `gh api` 调用 `PUT repos///pulls//merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。 -`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。每个状态快照把 `baseRefOid` 作为 `baseSha` 携带;拿到它时,`diff` 在 GraphQL 查询确认 head 的同时就开始这次比较,head 已过时或基准提交不同时会取消提前开始的比较并等它结束,然后才返回。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 +`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos///compare/...`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。每个状态快照把 `baseRefOid` 作为 `baseSha` 携带(GitHub 返回的不是完整提交时省略);拿到它时,`diff` 在 GraphQL 查询确认 head 的同时就开始这次比较,head 已过时或基准提交不同时会取消提前开始的比较并等它结束,然后才返回。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos///contents/?ref=`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://///blob//`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 diff --git a/packages/experimental/forge-github/src/graphql.ts b/packages/experimental/forge-github/src/graphql.ts index 92bcc4fbb2..ec97758af6 100644 --- a/packages/experimental/forge-github/src/graphql.ts +++ b/packages/experimental/forge-github/src/graphql.ts @@ -132,7 +132,8 @@ const pullRequestSchema = z.object({ headRefName: z.string(), headRefOid: z.string(), baseRefName: z.string(), - baseRefOid: z.string().refine(isCommit), + // Only the diff read's head start uses the base commit, so a missing or malformed one leaves the rest of the status intact. + baseRefOid: z.unknown().transform(value => typeof value === 'string' && isCommit(value) ? value : undefined), additions: z.number().int().nonnegative(), deletions: z.number().int().nonnegative(), mergeable: z.string(), @@ -467,7 +468,7 @@ function toSnapshot( headBranch: node.headRefName, headSha: node.headRefOid, baseBranch: node.baseRefName, - baseSha: node.baseRefOid, + ...node.baseRefOid === undefined ? {} : { baseSha: node.baseRefOid }, additions: node.additions, deletions: node.deletions, mergeability: mergeability(node.mergeable), diff --git a/packages/experimental/forge-github/tests/graphql.spec.ts b/packages/experimental/forge-github/tests/graphql.spec.ts index 58abd1f67f..36bb799343 100644 --- a/packages/experimental/forge-github/tests/graphql.spec.ts +++ b/packages/experimental/forge-github/tests/graphql.spec.ts @@ -45,6 +45,13 @@ describe('snapshot query', () => { }) describe('snapshot mapping', () => { + it.each([['missing', undefined], ['null', null], ['not a full commit', 'HEAD~1']])( + 'keeps the status and leaves out the base commit when GitHub reports it %s', async (_case, baseRefOid) => { + const snapshot = await single(pullRequestNode(1, { baseRefOid })) + expect(snapshot).not.toHaveProperty('baseSha') + expect(snapshot.headSha).toBe('f'.repeat(40)) + }) + it('maps the pull request fields into a provider-neutral snapshot', async () => { expect((await single(pullRequestNode(1, {}, [checkRun('lint', 'COMPLETED', 'SUCCESS')])))).toEqual({ ref: pull(1), From 1f0eb449a3438862e5307b44045caf52ed1b3870 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:09:09 +0800 Subject: [PATCH 59/81] fix(forge-panel): end a jump's alignment when the tab hides Also explains why the alignment measures every frame and why the section handlers read values assigned during render. --- .../client-ui-forge/src/client/ChangesTab.tsx | 11 +++++++++-- .../client-ui-forge/tests/changes-tab.client.spec.tsx | 11 ++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 4b4a06a920..cc01ad2a19 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -602,7 +602,9 @@ export function ChangesTab({ const releasePin = useRef<(() => void) | null>(null) /** * Keep a jumped-to section at the top of the scrollport for {@link JUMP_PIN_MS}: the sections around it draw their - * lines after the jump, which moves it. Scrolling input from the reader, another jump, or unmounting ends the pin. + * lines after the jump, which moves it. Scrolling input from the reader, another jump, hiding the tab, or unmounting + * ends the pin. Each frame measures the section once, which covers every cause of movement, such as a section + * drawing its lines, deferred highlighting, or a review card opening, without each one reporting it. */ const pinJump = (port: HTMLElement, section: HTMLElement, owner: ChangesData): void => { releasePin.current?.() @@ -644,6 +646,8 @@ export function ChangesTab({ if (section !== undefined && scrollport.current !== null) pinJump(scrollport.current, section, data) setWanted(null) }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id]) + // A hidden tab has nothing to keep in place, and its scrollport measures as empty. + useEffect(() => { if (!tab.visible) releasePin.current?.() }, [tab.visible]) useEffect(() => () => { releasePin.current?.() }, []) const refresh = (): void => { setAttempt(value => value + 1) } const root = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.root : null @@ -798,7 +802,10 @@ export function ChangesTab({ markViewed(data, file, viewedStateOf(marks, file.path, file.digest) !== 'viewed') } - // What the section handlers and comment actions read when they run; replaced on every render. + // What the section handlers and comment actions read when they run, replaced on every render. The assignment happens + // during render rather than in an effect because the file menus read their actions while this same render draws them; + // an effect would hand them the previous diff's links. A render React discards leaves values derived from the same + // props and state, which the retried render assigns again. const live = useRef({ data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t }) live.current = { data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t } const handlers = useMemo((): DiffFileHandlers => ({ diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index 69121f7e76..230f00809e 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -971,7 +971,7 @@ describe('ChangesTab layout', () => { expect(scrolled).toHaveBeenCalledWith({ block: 'start' }) }) - it('keeps a jumped-to file at the top while sections around it draw, until the reader scrolls or a second passes', async () => { + it('keeps a jumped-to file at the top while sections around it draw, until the reader scrolls, the tab hides, or a second passes', async () => { const m = mount() await settle() const { viewport } = fileViewport(m) @@ -1013,6 +1013,15 @@ describe('ChangesTab layout', () => { shift = 80 act(() => { vi.advanceTimersToNextFrame() }) expect(scrolled).toHaveBeenCalledTimes(3) + + // Hiding the tab ends the pin at once. + shift = 0 + jump() + expect(scrolled).toHaveBeenCalledTimes(4) + act(() => { m.visibility.set(false) }) + shift = 80 + act(() => { vi.advanceTimersToNextFrame() }) + expect(scrolled).toHaveBeenCalledTimes(4) } finally { vi.useRealTimers() } From c5054b5fffb4ec6459421d60727aee550ce99aed Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:09:09 +0800 Subject: [PATCH 60/81] docs(forge): record why the changes view measures pixels and how hosts mark the navigator row --- .../2026-11-07-forge-changes-view-responsiveness.i18n.yaml | 4 ++-- .../bug-fix/2026-11-07-forge-changes-view-responsiveness.md | 2 ++ .../2026-11-07-forge-changes-view-responsiveness.zh.md | 2 ++ packages/client/ui-sidebar-files/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar-files/README.md | 2 +- packages/client/ui-sidebar-files/README.zh.md | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml index 85b111788b..cc2e9c4b68 100644 --- a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml @@ -9,8 +9,8 @@ en: 77fab27f3d4a4a59 zh: 46e0fa8b9ef978a7 /agent-note-forge-changes-view-responsiveness/decision: - en: 8a0a9ef23a83ce2b - zh: 840d67e79ae39ff1 + en: a7127dc65ac6d22c + zh: 8582e17cf52db811 /agent-note-forge-changes-view-responsiveness/alternatives-considered: en: 9d12ccf1d236676d zh: 0ea63c5c16c62de7 diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md index 197552f117..73583334b2 100644 --- a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md @@ -18,6 +18,8 @@ The forge changes view of a large change request froze the browser. Opening #798 **Fewer serial reads.** A panel state equal to the published one reaches no follower. `changeRequestDiff` reads the patch and `.gitattributes` together. Snapshots carry the base commit as `baseSha`; `ForgeService.diff` passes it to the provider, and forge-github starts the compare read while its GraphQL read confirms the head. +**Pixel measurement.** Two parts measure layout in pixels, which CSS cannot replace. Whether a section is near the scrollport decides whether React renders its lines at all; `content-visibility` only skips layout and paint of nodes that already exist. Keeping a jumped-to section at the top needs its offset after each later layout change, since no CSS property scrolls a container to follow one child. The client packages offer no injected measurement hook, so both read the DOM directly, as the view's file navigation already does: the reach uses one `IntersectionObserver`, which measures off the main thread, and the pin reads two rectangles per frame for at most one second. + ## Alternatives considered **`content-visibility: auto` alone** cut style work as much as scoping the rules did, but kept the React render and Shiki tokenization of every expanded file at open, about 3 s of script, and its placeholder sizes would need estimates for every collapsed and expanded section. diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md index 80be6bf6fa..999ac762fc 100644 --- a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md @@ -18,6 +18,8 @@ Status: implemented **减少串行读取。** 与已发布状态相同的面板状态不会通知任何关注方。`changeRequestDiff` 同时读取补丁和 `.gitattributes`。快照把基准提交作为 `baseSha` 携带;`ForgeService.diff` 把它传给提供方,forge-github 在 GraphQL 读取确认 head 的同时就开始 compare 读取。 +**像素测量。** 有两处需要按像素测量布局,CSS 无法替代。文件段是否接近滚动区域,决定 React 是否渲染它的各行;`content-visibility` 只能跳过已有节点的布局与绘制。让跳转后的文件段保持在顶部,需要在之后每次布局变化后读取它的偏移,没有哪个 CSS 属性能让容器跟随某个子元素滚动。客户端包没有可注入的测量 hook,所以这两处像视图已有的文件导航一样直接读取 DOM:接近判断用一个 `IntersectionObserver`,在主线程之外测量;对齐每帧读取两个矩形,最多持续一秒。 + ## Alternatives considered **只用 `content-visibility: auto`** 减少的样式工作与限定规则相当,但打开时仍要对每个展开文件做 React 渲染和 Shiki 分词,约 3 秒脚本时间,而且每个折叠和展开的段都需要估计占位尺寸。 diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml index 0dfbf36f96..dd3d34d879 100644 --- a/packages/client/ui-sidebar-files/README.i18n.yaml +++ b/packages/client/ui-sidebar-files/README.i18n.yaml @@ -18,8 +18,8 @@ en: c4b8f3a52f68bd51 zh: 30b580320637572c /deepseek-ai-dsh-client-ui-sidebar-files/the-navigator: - en: 7e3aebc6079b023b - zh: 11911cc59fcec9b1 + en: 3424600b15bde2d2 + zh: 2cf1c99ffef2ad50 /deepseek-ai-dsh-client-ui-sidebar-files/the-tree: en: 3d71e50801761213 zh: e4e9ab19dd22288d diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md index 2aea08292d..3991ccec18 100644 --- a/packages/client/ui-sidebar-files/README.md +++ b/packages/client/ui-sidebar-files/README.md @@ -41,7 +41,7 @@ The [registration](src/client/index.ts) assembles the page, navigator, toggle, a ## The navigator -Render `sidebar.files.navigator` with `FileNavigatorInput`: `{ useTabInfo, page, onOpen }`. The required `onOpen: (address: string) => void` callback belongs to the containing plugin; the navigator builds the file address without choosing placement. `page` controls presentation only: `true` always fills the files page, while `false` follows the preview's pane preference and available width. +Render `sidebar.files.navigator` with `FileNavigatorInput`: `{ useTabInfo, page, onOpen }`. The required `onOpen: (address: string) => void` callback belongs to the containing plugin; the navigator builds the file address without choosing placement. `page` controls presentation only: `true` always fills the files page, while `false` follows the preview's pane preference and available width. Render the Factory as a direct child of the row it shares with the body, and mark that row with `data-files-navigator-row`: the layout that fills the row hides the row's other children only under that attribute, so a host without it leaves the covered body reachable by keyboard. The navigator is drawn below the preview's header, which spans the pane, at the right of the row it shares with the body. Beside a preview, its pane's record decides whether it shows: a pane starts with it hidden, and the choice holds for every file tab of the pane until the toggle, Escape, the workspace command, or navigation to a covered preview changes it. A pane without a record of its own, such as a floating panel a tab was dragged out into, starts hidden at the default width. A click outside the tree does not hide it, and opening content in another pane does not hide it either. On the files page the tree fills the page below the header, docked or floating, without a separator, and always shows: the page draws no navigator toggle and Escape leaves it. A file the page opens starts with the destination pane's own choice, hidden for a pane that has made none. diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md index 18e9caef84..5c5e776ad2 100644 --- a/packages/client/ui-sidebar-files/README.zh.md +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" ## 导航栏 -用 `FileNavigatorInput` 渲染 `sidebar.files.navigator`:`{ useTabInfo, page, onOpen }`。必填的 `onOpen: (address: string) => void` 回调由容纳它的插件提供;导航栏构造文件地址,不决定落位。`page` 只控制呈现:`true` 时始终铺满文件页,`false` 时遵循预览所在分栏的偏好与可用宽度。 +用 `FileNavigatorInput` 渲染 `sidebar.files.navigator`:`{ useTabInfo, page, onOpen }`。必填的 `onOpen: (address: string) => void` 回调由容纳它的插件提供;导航栏构造文件地址,不决定落位。`page` 只控制呈现:`true` 时始终铺满文件页,`false` 时遵循预览所在分栏的偏好与可用宽度。Factory 要作为它与正文共用的那一行的直接子元素渲染,并给该行加上 `data-files-navigator-row`:铺满整行的布局只在带这个属性的行里隐藏其他子元素,宿主不加时,被盖住的正文仍可用键盘访问。 导航栏画在横跨分栏的预览标题行下方,位于它与正文共用的那一行的右侧。在预览旁,是否显示由其分栏的记录决定:分栏默认隐藏,这一选择对分栏内的每个文件 tab 都有效,直到开关、Escape、工作区命令,或导航到被目录树遮住的预览改变它。自身没有记录的分栏(例如 tab 拖出后所在的浮窗)从隐藏、默认宽度开始。点击目录树以外的地方不会隐藏它;在另一分栏打开内容也不会隐藏它。在文件页上,目录树铺满标题行下方的整页,停靠与浮动时都一样,不显示分隔条,且始终显示:文件页不画导航栏开关,Escape 也不关闭它。文件页打开的文件沿用落定分栏自己的选择,尚未做过选择的分栏为隐藏。 From ac53809d66c89f83943f7d3378024634b80e2ca4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 18:18:03 +0800 Subject: [PATCH 61/81] refactor(cua): own system-pointer consent in the shared host --- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- packages/experimental/cua-macos/README.md | 6 +- packages/experimental/cua-macos/README.zh.md | 6 +- .../experimental/cua-macos/src/guidance.ts | 3 +- packages/experimental/cua-macos/src/index.ts | 23 +---- .../cua-macos/src/mouse-control.ts | 55 ----------- .../cua-macos/tests/guidance.spec.ts | 6 +- .../tests/protocol-generation.spec.ts | 11 +-- .../tests/provider-lifecycle.spec.ts | 58 ++++++----- packages/experimental/cua/README.i18n.yaml | 4 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/README.zh.md | 2 +- packages/experimental/cua/package.json | 5 - .../cua/src/client/ApplicationApproval.tsx | 4 +- packages/experimental/cua/src/config.ts | 1 + packages/experimental/cua/src/guidance.ts | 18 +++- packages/experimental/cua/src/index.ts | 32 ++++--- .../src/mouse-control-locales.ts | 0 .../experimental/cua/src/mouse-control.ts | 63 ++++++++++++ packages/experimental/cua/src/protocol.ts | 95 +++++++++++-------- .../cua/src/session-permissions.ts | 54 ----------- packages/experimental/cua/src/types.ts | 12 ++- packages/experimental/cua/tests/api.spec.ts | 2 +- .../application-approval.client.spec.tsx | 2 +- .../experimental/cua/tests/bridge.spec.ts | 2 +- .../cua/tests/fixtures/exports.mjs | 2 - .../experimental/cua/tests/protocol.spec.ts | 4 +- packages/experimental/cua/tsdown.config.ts | 1 - scripts/check-workspace-constraints.ts | 2 +- tsconfig.base.json | 1 - 31 files changed, 219 insertions(+), 265 deletions(-) delete mode 100644 packages/experimental/cua-macos/src/mouse-control.ts rename packages/experimental/{cua-macos => cua}/src/mouse-control-locales.ts (100%) create mode 100644 packages/experimental/cua/src/mouse-control.ts delete mode 100644 packages/experimental/cua/src/session-permissions.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 52d8ee4329..07e8c52196 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1156,15 +1156,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:48`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Ask once per session before the first explicitly requested system-pointer move. */ - requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 73281cb49a..8308df3e6f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1158,15 +1158,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:48`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Ask once per session before the first explicitly requested system-pointer move. */ - requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index ad896c5764..7b5df96219 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -187,7 +187,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -227,6 +227,8 @@ Available at app or window level depending on the platform. - sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. + +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. ``` #### Token effect diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 973613327c..8db5bee329 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -187,7 +187,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -227,6 +227,8 @@ Available at app or window level depending on the platform. - sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. + +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. ``` #### Token 影响 diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index c5c1a07381..0428941edb 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -6,7 +6,7 @@ export const PLATFORM_GUIDANCE = 'macOS: accessibility tree operations are avail const methodDetails = new Map([ ['getAccessibilityTree', 'If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current.'], - ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again.'], + ['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again.'], ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) @@ -16,7 +16,6 @@ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { if (method === 'pressKey') return `${entry} - sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.` - if (method === 'mouseMove') entry = entry.replace('relative?: boolean', 'relative?: boolean, useSystemPointer?: boolean') const detail = methodDetails.get(method) return detail ? `${entry} ${detail}` : entry })}` diff --git a/packages/experimental/cua-macos/src/index.ts b/packages/experimental/cua-macos/src/index.ts index 5156e30ab1..fb3fada07e 100644 --- a/packages/experimental/cua-macos/src/index.ts +++ b/packages/experimental/cua-macos/src/index.ts @@ -4,31 +4,16 @@ import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' import { installCua, type CuaConfig, type CuaStartupCapabilities } from '@deepseek-ai/dsh-cua' -import type { CuaCoordinateOptions, CuaElementId, CuaHoverResult } from '@deepseek-ai/dsh-cua/api' import { CuaConfigFields } from '@deepseek-ai/dsh-cua/config' import { createPermissionParameters } from './permission-guide.ts' import { nativeHostArchitecture, prepareNativeExecutable, type SourceSigning } from './native-build.ts' import { createSharedNativeChannel } from './shared-native.ts' -import { mouseMovePreparation, MacRequest } from './mouse-control.ts' import { installHeldKeys } from './held-keys.ts' import { GUIDANCE } from './guidance.ts' export { GUIDANCE } from './guidance.ts' -declare module '@deepseek-ai/dsh-cua/api' { - interface CuaWindow { - /** - * Request the macOS system pointer or explicitly select accessibility hover for this move. - * @param target Current element id or screenshot-local point. - * @param options Coordinate mode and requested macOS pointer behavior. - * @returns Dispatch evidence and the selected hover route. - */ - mouseMove(target: CuaElementId | readonly [number, number], - options: CuaCoordinateOptions & { useSystemPointer: boolean }): Promise - } -} - const CAPABILITIES: CuaStartupCapabilities = { - platform: 'macos', + platform: 'macos', systemPointer: true, methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', @@ -48,8 +33,6 @@ export const inject = ['computerUse', 'tools', 'systemPrompt', 'subprocess', 'ag export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Ask once per session before the first explicitly requested system-pointer move. */ - requireSystemPointerApproval: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. @@ -60,7 +43,6 @@ export interface Config extends CuaConfig { /** Validate shared operation bounds and macOS source signing before preparing the helper. */ export const Config = Schema.object({ ...CuaConfigFields, - requireSystemPointerApproval: Schema.boolean().default(true), permissionPollIntervalMs: Schema.number().step(1).min(100).max(2 ** 31 - 1).default(1000), sourceSigning: Schema.union([ Schema.const('ad-hoc'), @@ -80,8 +62,7 @@ export const Config = Schema.object({ */ export async function apply(ctx: Context, config: Config): Promise { const backend = { createChannel: createSharedNativeChannel, sendKeyEvent: installHeldKeys(ctx), - guidance: GUIDANCE, capabilities: CAPABILITIES, requestSchema: MacRequest, - prepareMouseMove: mouseMovePreparation(ctx, config.requireSystemPointerApproval), + guidance: GUIDANCE, capabilities: CAPABILITIES, permissionParameters: createPermissionParameters(config.permissionPollIntervalMs) } if (config.helperPath !== undefined) { if (!isAbsolute(config.helperPath)) throw new Error('dsh-cua helperPath must be absolute') diff --git a/packages/experimental/cua-macos/src/mouse-control.ts b/packages/experimental/cua-macos/src/mouse-control.ts deleted file mode 100644 index 94499ffa8a..0000000000 --- a/packages/experimental/cua-macos/src/mouse-control.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** macOS system-pointer options and session approval for physical hover. */ -import type { Context } from '@deepseek-ai/cordis' -import type { CuaBackend } from '@deepseek-ai/dsh-cua' -import { CuaSessionPermissions } from '@deepseek-ai/dsh-cua/session-permissions' -import { NativeError } from '@deepseek-ai/dsh-cua/native-client' -import { MouseMoveTargets, Request } from '@deepseek-ai/dsh-cua/protocol' -import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' -import { z } from 'zod' -import { mouseControlPrompt } from './mouse-control-locales.ts' - -const pointerOptions = { useSystemPointer: z.boolean().optional().describe('Request use of the system pointer. The Host checks permission before dispatch.') } -const mouseMove = z.strictObject({ method: z.literal('mouseMove'), params: z.union(MouseMoveTargets.map(target => target.extend(pointerOptions))) }) - -const [first, ...rest] = Request.options -const macOperation = (op: typeof Request.options[number]) => op.shape.method.value === 'mouseMove' ? mouseMove : op - -/** macOS advertises and validates its additional option for element and coordinate moves. */ -export const MacRequest = z.discriminatedUnion('method', [macOperation(first), ...rest.map(macOperation)]) - -/** - * Select the native route on the provider's parsed request, before coordinates are rechecked and dispatched. - * @param ctx Provider context owning this set of Session decisions. - * @param requireApproval Whether physical moves need the user's approval. - * @returns Preparation for mouseMove with an optional user interaction. - */ -export function mouseMovePreparation(ctx: Context, requireApproval: boolean): NonNullable { - const permissions = new CuaSessionPermissions(ctx) - const permission = 'system-pointer' - return (agent, params) => { - if (params.useSystemPointer !== true) { - params.useSystemPointer = false - return undefined - } - if (agent.session.header.origin === 'subagent') { - throw new NativeError('MOUSE_CONTROL_UNAVAILABLE', "Delegated tasks cannot move the user's pointer. Use AX hover, click, or performAction. If the task requires a physical hover, report that step in your final result for the main task.") - } - if (!requireApproval) return undefined - params.useSystemPointer = false - return async (signal) => { - try { - if (!await permissions.request(agent, permission, mouseControlPrompt, signal)) { - throw new NativeError('MOUSE_CONTROL_DENIED', 'The user declined system-pointer movement for this session. No input was sent. Use pointer-free actions.') - } - } catch (error) { - if (error instanceof UserQuestionError) { - if (error.code === 'APPROVAL_PENDING') throw new NativeError('MOUSE_CONTROL_PENDING', error.message, { cause: error }) - if (error.code === 'NO_PROVIDER') throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', error.message, { cause: error }) - if (error.code === 'ASK_CANCELLED') throw new NativeError('MOUSE_APPROVAL_CANCELLED', 'The mouse control request was dismissed. Continue without mouse control unless the user asks to reconsider', { cause: error }) - } - throw error - } - params.useSystemPointer = true - } - } -} diff --git a/packages/experimental/cua-macos/tests/guidance.spec.ts b/packages/experimental/cua-macos/tests/guidance.spec.ts index 7f0cb9ea86..54d384a688 100644 --- a/packages/experimental/cua-macos/tests/guidance.spec.ts +++ b/packages/experimental/cua-macos/tests/guidance.spec.ts @@ -34,12 +34,12 @@ it('states the macOS accessibility, keyboard, and mouse levels', () => { expect(pressKey).toBe(COMMON_GUIDANCE.split('\n').find(line => line.startsWith('- pressKey('))) expect(GUIDANCE).toContain('W down, D down, D up, W up') const hover = GUIDANCE.split('\n').find(line => line.startsWith('- mouseMove(')) - for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE', 'The question interrupts the user']) expect(hover).toContain(term) + for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE']) expect(hover).toContain(term) const click = GUIDANCE.split('\n').find(line => line.startsWith('- click(')) expect(click).toContain('await app.getWindow(windowId), then call win.click(target)') expect(click).toContain('use app.invoke(id)') for (const step of ['call app.activate() first', 'outside the window', 'screenshot pixels', 'before typing or retrying']) expect(click).toContain(step) - for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'The question interrupts the user', 'mouse-down']) { + for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'mouse-down']) { expect(COMMON_GUIDANCE).not.toContain(platformDetail) } }) @@ -72,7 +72,7 @@ it('inlines discovery, restoration, screenshot, and mouse-move return records', for (const name of ['cua.getCapabilities', 'cua.listApps', 'cua.findApps', 'cua.getState', 'app.getWindows', 'app.show', 'window.show', 'getScreenshot', 'mouseMove']) { expect(lines.find(line => line.startsWith(`- ${name}(`))).toMatch(/ -> \(?\{ /) } - expect(COMMON_GUIDANCE).toContain("{ platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false }") + expect(COMMON_GUIDANCE).toContain("{ platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false }") expect(COMMON_GUIDANCE).toContain('{ id: string, displayName: string, isRunning: true, pid: number }[]') expect(COMMON_GUIDANCE).toContain('({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[]') expect(COMMON_GUIDANCE).toContain('bounds: { x: number, y: number, width: number, height: number }, isOnScreen: boolean, onCurrentSpace: boolean | null') diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index 76d3bbff12..c83c16e77e 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -3,8 +3,7 @@ import { readFile } from 'node:fs/promises' import { Ajv2020 } from 'ajv/dist/2020.js' import { describe, expect, it } from 'vitest' import { renderNativeProtocol } from '../native/generate-protocol.ts' -import { Request } from '@deepseek-ai/dsh-cua/protocol' -import { MacRequest } from '../src/mouse-control.ts' +import { Request, SystemPointerRequest } from '@deepseek-ai/dsh-cua/protocol' describe('native protocol generation', () => { it('keeps native JSON Schema and Swift admission tables current', async () => { @@ -29,13 +28,13 @@ describe('native protocol generation', () => { expect(validate({ id: 'shortcut', ...request })).toBe(false) }) - it('admits the pointer option only in the macOS model schema and only as a boolean', () => { + it('admits the pointer option only in the system-pointer model schema and only as a boolean', () => { const target = { appHandle: 'app', windowId: 1, x: 0, y: 0 } const request = { method: 'mouseMove', params: { ...target, useSystemPointer: true } } expect(Request.safeParse(request).success).toBe(false) - expect(MacRequest.safeParse(request).success).toBe(true) - expect(MacRequest.safeParse({ ...request, params: { ...target, useSystemPointer: 'true' } }).success).toBe(false) - expect(MacRequest.safeParse({ ...request, params: { ...target, arbitraryField: true } }).success).toBe(false) + expect(SystemPointerRequest.safeParse(request).success).toBe(true) + expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, useSystemPointer: 'true' } }).success).toBe(false) + expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, arbitraryField: true } }).success).toBe(false) }) it.each([ diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 33f87e5866..343bbbdb33 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -17,8 +17,8 @@ import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import Hmr from '@deepseek-ai/dsh-hmr' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use' +import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance' import { CuaConfig, installCua } from '@deepseek-ai/dsh-cua' -import { CuaSessionPermissions } from '@deepseek-ai/dsh-cua/session-permissions' import { createConfigProjector } from '@deepseek-ai/dsh-app-boot/src/config-schema/projector.ts' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import FileSystem from '@deepseek-ai/dsh-fs-local' @@ -289,7 +289,7 @@ it('merges startup capabilities into runtime native fields', async () => { native.request.mockResolvedValueOnce(runtime) const result = await f.execute('getCapabilities') expect(result.isError).toBe(false) - expect(result.value).toEqual({ ...runtime, platform: 'macos', appLevelAccessibility: true, keyboard: ['app'], browser: false, + expect(result.value).toEqual({ ...runtime, platform: 'macos', systemPointer: true, appLevelAccessibility: true, keyboard: ['app'], browser: false, coordinateSpace: 'screenshot-pixels', methods: [ 'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke', 'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll', @@ -982,7 +982,7 @@ async function hoverFixture(requireApproval = true) { await f.ctx.plugin(NativeProvider, { ...config, requireSystemPointerApproval: false }) } const questions = await f.ctx.plugin(UserQuestions) - const questionId = 'cua-session-permission:system-pointer' + const questionId = 'cua-mouse-control' const approved = { answers: [{ id: questionId, selected: ['Allow for this session'] }] } const ask = vi.fn(async () => approved) f.ctx.on('user-questions/request', async ({ questions }) => questions[0]!.id === questionId ? ask() @@ -1032,7 +1032,7 @@ it('keeps refusals and grants separate and forgets a closed session on reopen', const { agent } = await f.ctx.agents.create({ sessionId: SessionId('other-mouse-session') }) expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('APP_ACCESS_REQUIRED') await f.select(agent) - f.ask.mockResolvedValueOnce({ answers: [{ id: 'cua-session-permission:system-pointer', selected: ['Deny'] }] }) + f.ask.mockResolvedValueOnce({ answers: [{ id: 'cua-mouse-control', selected: ['Deny'] }] }) native.request.mockClear() for (let i = 0; i < 2; i++) expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('MOUSE_CONTROL_DENIED') expect(native.request).not.toHaveBeenCalled() @@ -1120,39 +1120,35 @@ it.each([true, false])('rejects a delegated pointer move before asking or dispat expect(native.request).not.toHaveBeenCalled() }) -it('keeps provider-specific pointer flags and questions off providers without mouse control', async () => { +it.each([true, false])('uses the declared systemPointer capability for a non-macOS provider (%s)', async (systemPointer) => { const f = await setup() await f.provider.dispose() await f.ctx.plugin({ name: 'fixture-window-provider', inject: NativeProvider.inject, apply(ctx: Context) { installCua(ctx, CuaConfig(config), { - helperPath: config.helperPath, createChannel: native.connect, - guidance: '', permissionParameters: () => ({}), - capabilities: { platform: 'fixture', methods: ['getApp', 'mouseMove'], appLevelAccessibility: true, - keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, + helperPath: config.helperPath, createChannel: native.connect, guidance: COMMON_GUIDANCE, + permissionParameters: () => ({}), + capabilities: { platform: 'fixture', methods: ['getApp', 'getCapabilities', 'mouseMove'], systemPointer, + appLevelAccessibility: true, keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false }, }) } }) + native.request.mockResolvedValueOnce({ systemPointer: !systemPointer }) + expect((await f.execute('getCapabilities')).value).toMatchObject({ platform: 'fixture', systemPointer }) + const sections = (await f.ctx.systemPrompt.assemble({ scope: f.handle.agent })).sections + const guidance = sections.find(section => section.name === 'computer-use:dsh-cua')!.text + expect(guidance.includes('useSystemPointer')).toBe(systemPointer) + expect(guidance.includes('The question interrupts the user')).toBe(systemPointer) + expect(JSON.stringify(f.ctx.tools.get('cua', f.handle.agent)!.parameters).includes('useSystemPointer')).toBe(systemPointer) await selectApplication(f) - expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('useSystemPointer') - const target = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' } - expect((await f.execute('mouseMove', target)).isError).toBe(false) - expect(native.request).toHaveBeenLastCalledWith('mouseMove', target, expect.any(AbortSignal), expect.any(String)) -}) - -it('separates a provider’s permission decisions and presents its question for each capability', async () => { - const f = await setup() - await f.ctx.plugin(UserQuestions) - const permissions = new CuaSessionPermissions(f.ctx) - const signal = new AbortController().signal - const ask = vi.spyOn(f.ctx.userQuestions, 'ask').mockImplementation(async ({ questions }) => { - const q = questions[0]! - return { answers: [{ id: q.id, selected: [q.question === 'Read status?' ? 'Allow for this session' : 'Deny'] }] } + native.request.mockClear() + const answer = vi.spyOn(f.ctx.userQuestions, 'ask').mockResolvedValue({ + answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }], }) - const read = { question: { en: 'Read status?' }, detail: { en: 'Only for this session' } } - const write = { question: { en: 'Change status?' }, detail: { en: 'Sets the current status' } } - const agent = f.handle.agent - await expect(permissions.request(agent, 'read-status', read, signal)).resolves.toBe(true) - await expect(permissions.request(agent, 'write-status', write, signal)).resolves.toBe(false) - await expect(permissions.request(agent, 'write-status', write, signal)).resolves.toBe(false) - await expect(permissions.request(agent, 'read-status', read, signal)).resolves.toBe(true) - expect(ask.mock.calls.map(([request]) => request.questions[0]!.detail)).toEqual([read.detail.en, write.detail.en]) + const physical = await f.execute('mouseMove', physicalHover) + expect(physical.isError).toBe(!systemPointer) + expect(answer).toHaveBeenCalledTimes(systemPointer ? 1 : 0) + if (systemPointer) expect(native.request).toHaveBeenLastCalledWith('mouseMove', physicalHover, expect.any(AbortSignal), expect.any(String)) + else expect(native.request).not.toHaveBeenCalled() + expect((await f.execute('mouseMove', hoverTarget)).isError).toBe(false) + expect(native.request).toHaveBeenLastCalledWith('mouseMove', systemPointer ? { ...hoverTarget, useSystemPointer: false } : hoverTarget, + expect.any(AbortSignal), expect.any(String)) }) diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index 851909295c..40d8ac79c6 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -21,8 +21,8 @@ en: d92d5948a7941e8d zh: d43491ae6521b87e /deepseek-ai-dsh-cua/understand-the-implementation: - en: edb0b08ad9ee9a73 - zh: da868fc1061516e4 + en: f7d47911abb5e2d8 + zh: 227645c9df979455 /deepseek-ai-dsh-cua/further-exploration: en: c174fb5a00ea3681 zh: 039340042676881d diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index 5eea482387..1ee0a555d1 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -68,7 +68,7 @@ The Host marker has no configurable fields; platform providers own native config ## Understand the implementation -Import `CuaSessionPermissions` from `@deepseek-ai/dsh-cua/session-permissions`. Its `request(agent, permission, prompt, signal)` method asks for one named permission using the provider’s localized question and detail. A live Session caches each permission separately. Providers declare additional tool fields with their request schema and can prepare a move before dispatch. +Providers declare `systemPointer: true` to support explicit physical pointer movement. CUA exposes the option in the tool schema and model instructions only for those providers. CUA owns the approval card, live-session decision and delegated-task restriction. A provider receives the per-call pointer mode after CUA checks application access and any required consent. Permission responses identify their platform and report only the state that platform can observe. The shared page renders the controls those observations support. Before platform identification, it shows neutral observation and input rows without system actions. Native providers use the shared build lock and compiler cleanup. Standalone executables are retained by digest. The macOS provider reads its signed application receipt after compilation completes under the same lock. diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index 2db56fd1b2..b8592b36eb 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -68,7 +68,7 @@ Computer Use 详情页的权限卡片、组件列表和来源信息使用 l3 边 ## 理解实现 -提供方从 `@deepseek-ai/dsh-cua/session-permissions` 导入 `CuaSessionPermissions`,通过 `request(agent, permission, prompt, signal)` 申请指定权限,并传入本地化的问题和说明。每个会话分别记录各项权限的决定。提供方通过请求 schema 声明额外字段,可在派发前处理悬停请求。 +提供方通过 `systemPointer: true` 声明支持真实鼠标移动。CUA 据此在工具 schema 和模型提示词中加入该选项,负责显示授权卡、记录当前会话的决定并限制子代理。CUA 检查应用访问权限和本次移动所需的授权后,将悬停方式传给提供方。 权限响应标识所属平台,并且只报告该平台能够观察到的状态。共享页面显示这些观察结果支持的控件。平台尚未确认时显示通用观察和输入项,不提供系统操作。原生提供方共用编译锁和编译进程清理逻辑。独立可执行文件按内容摘要保存。macOS 提供方在锁内等待编译完成,再读取签名应用的构建回执。 diff --git a/packages/experimental/cua/package.json b/packages/experimental/cua/package.json index 5d5c6bc0fb..440f321935 100644 --- a/packages/experimental/cua/package.json +++ b/packages/experimental/cua/package.json @@ -53,10 +53,6 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, - "./session-permissions": { - "types": "./lib/types/session-permissions.d.ts", - "default": "./lib/session-permissions.js" - }, "./config": { "types": "./lib/types/config.d.ts", "default": "./lib/config.js" @@ -82,7 +78,6 @@ "lib/api.js", "lib/bridge.js", "lib/native-client.js", - "lib/session-permissions.js", "lib/protocol.js", "lib/config.js", "lib/native-schema.js", diff --git a/packages/experimental/cua/src/client/ApplicationApproval.tsx b/packages/experimental/cua/src/client/ApplicationApproval.tsx index c55f3ead50..af577d3c2a 100644 --- a/packages/experimental/cua/src/client/ApplicationApproval.tsx +++ b/packages/experimental/cua/src/client/ApplicationApproval.tsx @@ -5,14 +5,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client' import type {} from '@deepseek-ai/dsh-client-ui-approval/client' /** - * Select application access and provider Session permission questions; other interactions keep their renderer. + * Select application access and mouse-control questions; other interactions keep their renderer. * @param pending The session's currently elected interaction. * @returns Its Computer Use decision, or null for another interaction. */ export function selectApplicationApproval(pending: ComposerChainProps['pendingInteraction']) { if (pending?.kind !== 'question' || pending.questions.length !== 1) return null const item = pending.questions[0] as (typeof pending.questions)[number] - if ((item.id !== 'cua-app-access' && !item.id.startsWith('cua-session-permission:')) || item.intent?.kind !== 'approval') return null + if ((item.id !== 'cua-app-access' && item.id !== 'cua-mouse-control') || item.intent?.kind !== 'approval') return null return { pending, item, intent: item.intent } } diff --git a/packages/experimental/cua/src/config.ts b/packages/experimental/cua/src/config.ts index a570d4a93b..4e60c1e363 100644 --- a/packages/experimental/cua/src/config.ts +++ b/packages/experimental/cua/src/config.ts @@ -3,6 +3,7 @@ import Schema from '@deepseek-ai/schemastery' /** Shared configuration fields extended by native platform providers. */ export const CuaConfigFields = { + requireSystemPointerApproval: Schema.boolean().default(true), agentPreset: Schema.union([Schema.string().min(1), Schema.const(false)]).default('ptc'), allowedApps: Schema.array(Schema.object({ id: Schema.string().pattern(/^[a-f0-9]{64}$/).required(), diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index d8d7d46660..5e3f5cfc95 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -1,4 +1,6 @@ /** Shared model instructions for native application and window operations. */ +const hoverSignature = 'mouseMove(target: string | [number, number], options?: { relative?: boolean })' + /** Async method catalog with consent and observation rules, grouped by observation, mouse, and keyboard. */ export const COMMON_GUIDANCE = `Use cua to observe and operate native applications. Always use cua.getCapabilities() to confirm whether a specific API is available on the current platform; its reported capabilities take precedence over availability statements in these instructions. All methods are async; observation options default to { emit: true }, and emit: false suppresses sink output only, leaving returned values unchanged. Actions report dispatch evidence, not verified completion. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI actions and ask the user to unlock the desktop session; null or unknown means verify a usable unlocked desktop session first. cua.getApp requests user consent; if access is denied or revoked, stop using that application until the user explicitly permits another request, and never bypass a refusal or switch tools to avoid it. Never replay uncertain input without a fresh observation. @@ -7,7 +9,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -32,7 +34,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. +- ${hoverSignature} -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -46,3 +48,15 @@ Available at app or window level depending on the platform. - pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name.` + +/** + * Describe the system-pointer option for a provider that declares the capability. + * @param guidance Provider instructions including the shared operation catalog. + * @returns The capability-specific signature and permission instructions. + */ +export function withSystemPointerGuidance(guidance: string): string { + const signature = 'mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean })' + return `${guidance.replace(hoverSignature, signature)} + +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement.` +} diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index 731ae38157..43ca13308e 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -13,11 +13,13 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { z } from 'zod' import { NativeClient, NativeError, type NativeClientConfig, type NativeChannelFactory } from './native-client.ts' +import { MouseControl } from './mouse-control.ts' +import { withSystemPointerGuidance } from './guidance.ts' import { CuaPermissionStore } from './permission-store.ts' import { ApplicationAuthorizationResult, installCuaAccess } from './access.ts' import type { CuaApplicationGrant, CuaApplicationId, CuaSystemPermission } from './access-types.ts' import type { CuaAppHandle, CuaOperation, CuaStartupCapabilities } from './types.ts' -import { ApplicationIcon, AppResult, NativeScreenshotResult, Request, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts' +import { ApplicationIcon, AppResult, NativeScreenshotResult, Request, SystemPointerRequest, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts' import { CuaConfigFields } from './config.ts' import { CuaCoordinates } from './coordinates.ts' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' @@ -34,6 +36,8 @@ export type { CuaStartupCapabilities } from './types.ts' /** Native executable and per-operation resource bounds. */ export interface CuaConfig { + /** Ask once per live Session before an explicitly requested system-pointer move. */ + requireSystemPointerApproval: boolean /** Register tools for Agents using this preset; defaults to ptc. False enables global registration. */ agentPreset: string | false /** Legacy grants imported once into profile-scoped storage; later edits are ignored. */ @@ -126,10 +130,6 @@ export interface CuaBackend { createChannel: NativeChannelFactory guidance: string capabilities: CuaStartupCapabilities - /** Model request schema including this provider's operation options. Defaults to the shared schema. */ - requestSchema?: z.ZodType - /** Resolve provider options on the parsed copy. An optional interactive step runs before authorized dispatch. */ - prepareMouseMove?: (agent: Agent, params: Record) => ((signal: AbortSignal) => Promise) | undefined /** Dispatch a directional key through the provider's execution-scoped input owner, after application authorization. */ sendKeyEvent?: (client: NativeClient, params: Record, execution: Readonly) => Promise @@ -151,7 +151,11 @@ export interface CuaBackend { */ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend): void { const { helperPath } = backend - const guidance = `${backend.guidance}\n\nMaximum click holdMs: ${config.maxClickHoldMs} ms.` + const systemPointer = backend.capabilities.systemPointer === true + const mouseControl = systemPointer ? new MouseControl(ctx, config.requireSystemPointerApproval) : undefined + const requestSchema = systemPointer ? SystemPointerRequest : Request + const instructions = systemPointer ? withSystemPointerGuidance(backend.guidance) : backend.guidance + const guidance = `${instructions}\n\nMaximum click holdMs: ${config.maxClickHoldMs} ms.` const native: NativeClientConfig = { helperPath, cwd: process.cwd(), timeoutMs: config.timeoutMs, axTimeoutMs: config.axTimeoutMs, connectTimeoutMs: config.connectTimeoutMs, screenshotTimeoutMs: config.screenshotTimeoutMs, @@ -163,7 +167,6 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) permissionParameters: backend.permissionParameters, store: new CuaPermissionStore(ctx, config.allowedApps.map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), config.timeoutMs), }) - const requestSchema = backend.requestSchema ?? Request const clients = new Map() const lifetime = new AbortController() const pending = new Map, Agent>() @@ -212,13 +215,14 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) for (const result of [...await permissions, ...results, ...releases]) if (result.status === 'rejected') errors.push(result.reason) if (errors.length > 0) throw new AggregateError(errors, 'dsh-cua helper cleanup failed') } - const callNative = async (request: CuaOperation, execution: Readonly) => { + const callNative = async (request: CuaOperation, execution: Readonly, useSystemPointer?: boolean) => { const { method, params: parsedParams } = request const params = execution.coordinates.parameters(request) const agent = execution.agent const signal = execution.signal signal.throwIfAborted() const client = execution.nativeClient + if (useSystemPointer !== undefined) params.useSystemPointer = useSystemPointer if (method === 'sendKeyEvent' && backend.sendKeyEvent) return backend.sendKeyEvent(client, params, execution) const nativeParams = method === 'findApps' ? { ...params, maxAppMatches: config.maxAppMatches } @@ -282,7 +286,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) const capabilities = backend.capabilities return { ...runtime, - platform: capabilities.platform, + platform: capabilities.platform, systemPointer, methods: [...capabilities.methods], appLevelAccessibility: capabilities.appLevelAccessibility, keyboard: [...capabilities.keyboard], @@ -340,16 +344,18 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) owner.coordinates = new CuaCoordinates() } const nativeClient = client - const prepare = request.method === 'mouseMove' ? backend.prepareMouseMove?.(agent, request.params) : undefined - if (prepare) owner.coordinates.parameters(request) + const route = request.method === 'mouseMove' + ? mouseControl?.resolve(agent, 'useSystemPointer' in request.params && request.params.useSystemPointer === true) + : undefined + if (route?.prepare) owner.coordinates.parameters(request) const dispatch = async (signal: AbortSignal) => { const scoped = { ...execution, agent, signal, nativeClient, coordinates: owner.coordinates } - const result = await callNative(request, scoped) + const result = await callNative(request, scoped, route?.useSystemPointer) signal.throwIfAborted() return result } return 'appHandle' in request.params - ? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch, prepare) + ? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch, route?.prepare) : dispatch(signal) })() pending.set(operation, agent) diff --git a/packages/experimental/cua-macos/src/mouse-control-locales.ts b/packages/experimental/cua/src/mouse-control-locales.ts similarity index 100% rename from packages/experimental/cua-macos/src/mouse-control-locales.ts rename to packages/experimental/cua/src/mouse-control-locales.ts diff --git a/packages/experimental/cua/src/mouse-control.ts b/packages/experimental/cua/src/mouse-control.ts new file mode 100644 index 0000000000..866defca76 --- /dev/null +++ b/packages/experimental/cua/src/mouse-control.ts @@ -0,0 +1,63 @@ +/** Session approval for explicit physical pointer moves, shared by capable CUA providers. */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' +import { NativeError } from './native-client.ts' +import { applicationAccessDisplay } from './locales.ts' +import { mouseControlPrompt } from './mouse-control-locales.ts' + +/** Access is scoped to the live Session and never grants application access. */ +export class MouseControl { + private readonly decisions = new WeakMap() + + constructor(private readonly ctx: Context, private readonly requireApproval: boolean) {} + + /** + * Select the per-call route and any approval required before dispatch. + * @param agent Agent owning the live Session. + * @param requested Whether the caller explicitly requested system-pointer movement. + * @returns Native pointer mode and an optional preparation that must succeed before input. + */ + resolve(agent: Agent, requested: boolean): { useSystemPointer: boolean; prepare?: (signal: AbortSignal) => Promise } { + if (!requested) return { useSystemPointer: false } + if (agent.session.header.origin === 'subagent') { + throw new NativeError('MOUSE_CONTROL_UNAVAILABLE', "Delegated tasks cannot move the user's pointer. Use AX hover, click, or performAction. If the task requires a physical hover, report that step in your final result for the main task.") + } + if (!this.requireApproval || this.decisions.get(agent) === true) return { useSystemPointer: true } + return { useSystemPointer: true, prepare: signal => this.approve(agent, signal) } + } + + private async approve(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + const decision = this.decisions.get(agent) + if (decision === 'asking') throw new NativeError('MOUSE_CONTROL_PENDING', 'Wait for the outstanding mouse control request') + let granted = decision + if (granted === undefined) { + const questions = this.ctx.get('userQuestions') + if (!questions) throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', 'No human answerer is available for mouse control') + const id = 'cua-mouse-control' + const display = { ...mouseControlPrompt, approve: applicationAccessDisplay.approve } + const approve = display.approve.en + const reject = 'Deny' + this.decisions.set(agent, 'asking') + try { + const result = await questions.ask({ agent, signal, questions: [{ + id, question: display.question.en, detail: display.detail.en, options: [{ label: approve }, { label: reject }], + intent: { kind: 'approval', approve, reject, display }, + }] }) + signal.throwIfAborted() + const [answer] = result.answers + granted = result.answers.length === 1 && answer?.id === id && !answer.custom?.trim() + && answer.selected.length === 1 && answer.selected[0] === approve + this.decisions.set(agent, granted) + } catch (error) { + this.decisions.delete(agent) + if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { + throw new NativeError('MOUSE_APPROVAL_CANCELLED', 'The mouse control request was dismissed. Continue without mouse control unless the user asks to reconsider', { cause: error }) + } + throw error + } + } + if (!granted) throw new NativeError('MOUSE_CONTROL_DENIED', 'The user declined system-pointer movement for this session. No input was sent. Use pointer-free actions.') + } +} diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 1fd9d6cd41..7684bb4aca 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -39,48 +39,57 @@ export const MouseMoveTargets = [ z.object(snapshot).strict(), z.object(absoluteCoordinate).strict(), z.object(relativeCoordinate).strict(), ] as const -/** Model-submittable operation parameters; Host resource fields and private continuations are separate. */ -export const Request = z.discriminatedUnion('method', [ - operation('getCapabilities', {}), operation('listApps', {}), - operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }), - operation('getApp', { selector: z.string().min(1) }), - operation('activate', app), - operation('show', { ...app, windowId: id.optional() }), - operation('listWindows', app), - operation('getState', { ...app, windowId: id.optional(), - includeMenus: z.boolean().optional().describe('Expand menu contents; omitted or false shows menu-bar entries only.'), - details: z.boolean().optional().describe('Include identifiers, global native bounds and complete action names.'), - }), - operation('listActions', snapshot), - operation('performAction', { ...snapshot, action: z.string().min(1), argument: jsonValue.optional() }), - operation('invoke', snapshot), - z.object({ method: z.literal('mouseMove'), params: z.union(MouseMoveTargets) }).strict(), - z.object({ method: z.literal('click'), params: z.union([ - z.object({ ...snapshot, ...click }).strict(), - z.object({ ...absoluteCoordinate, ...click }).strict(), - z.object({ ...relativeCoordinate, ...click }).strict(), - ]) }).strict(), - operation('setValue', { ...snapshot, value: jsonValue }), - operation('selectText', { ...snapshot, text: z.string().min(1), prefix: z.string().optional(), suffix: z.string().optional(), - selectionType: z.enum(['text', 'cursor_before', 'cursor_after']).optional() }), - operation('typeText', { ...app, windowId: id.optional(), text: z.string() }), - operation('paste', { ...app, windowId: id.optional(), text: z.string(), format: z.enum(['text', 'html']).optional() }), - operation('pressKey', { ...app, windowId: id.optional(), key: z.string().min(1), modifiers: z.array(z.string()).optional() }), - operation('sendKeyEvent', { ...app, windowId: id.optional(), key: z.string().min(1), direction: z.enum(['down', 'up']) }), - z.object({ method: z.literal('scroll'), params: z.union([ - z.object({ ...absoluteCoordinate, - deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(), - z.object({ ...relativeCoordinate, - deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(), - ]) }).strict(), - z.object({ method: z.literal('drag'), params: z.union([ - z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: z.literal(false).optional(), - fromX: point, fromY: point, toX: point, toY: point }).strict(), - z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: relativeCoordinate.relative, - fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(), - ]) }).strict(), - operation('screenshot', window), -]) +function requestSchema(hover: T) { + return z.discriminatedUnion('method', [ + operation('getCapabilities', {}), operation('listApps', {}), + operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }), + operation('getApp', { selector: z.string().min(1) }), + operation('activate', app), + operation('show', { ...app, windowId: id.optional() }), + operation('listWindows', app), + operation('getState', { ...app, windowId: id.optional(), + includeMenus: z.boolean().optional().describe('Expand menu contents; omitted or false shows menu-bar entries only.'), + details: z.boolean().optional().describe('Include identifiers, global native bounds and complete action names.'), + }), + operation('listActions', snapshot), + operation('performAction', { ...snapshot, action: z.string().min(1), argument: jsonValue.optional() }), + operation('invoke', snapshot), + z.object({ method: z.literal('mouseMove'), params: hover }).strict(), + z.object({ method: z.literal('click'), params: z.union([ + z.object({ ...snapshot, ...click }).strict(), + z.object({ ...absoluteCoordinate, ...click }).strict(), + z.object({ ...relativeCoordinate, ...click }).strict(), + ]) }).strict(), + operation('setValue', { ...snapshot, value: jsonValue }), + operation('selectText', { ...snapshot, text: z.string().min(1), prefix: z.string().optional(), suffix: z.string().optional(), + selectionType: z.enum(['text', 'cursor_before', 'cursor_after']).optional() }), + operation('typeText', { ...app, windowId: id.optional(), text: z.string() }), + operation('paste', { ...app, windowId: id.optional(), text: z.string(), format: z.enum(['text', 'html']).optional() }), + operation('pressKey', { ...app, windowId: id.optional(), key: z.string().min(1), modifiers: z.array(z.string()).optional() }), + operation('sendKeyEvent', { ...app, windowId: id.optional(), key: z.string().min(1), direction: z.enum(['down', 'up']) }), + z.object({ method: z.literal('scroll'), params: z.union([ + z.object({ ...absoluteCoordinate, + deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(), + z.object({ ...relativeCoordinate, + deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(), + ]) }).strict(), + z.object({ method: z.literal('drag'), params: z.union([ + z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: z.literal(false).optional(), + fromX: point, fromY: point, toX: point, toY: point }).strict(), + z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: relativeCoordinate.relative, + fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(), + ]) }).strict(), + operation('screenshot', window), + ]) +} + +/** Portable model operations, including auxiliary hover without a system-pointer option. */ +export const Request = requestSchema(z.union(MouseMoveTargets)) + +/** Model operations for providers that can move the user's system pointer. */ +export const SystemPointerRequest = requestSchema(z.union(MouseMoveTargets.map(target => target.extend({ + useSystemPointer: z.boolean().optional().describe('Request use of the system pointer. The Host checks permission before dispatch.'), +})))) /** Resource parameters supplied by the Host, never accepted from model tool arguments. */ export const NativeResourceParameters = { @@ -139,6 +148,8 @@ export const CapabilitiesResult = z.object({ platform: z.string(), permissions: z.object({ accessibility: z.boolean(), screenRecording: z.boolean() }), /** Whether the provider supports application-level accessibility observations. */ appLevelAccessibility: z.boolean(), + /** Whether mouseMove accepts an explicit request to move the system pointer. */ + systemPointer: z.boolean().default(false), /** Scopes that support keyboard input. */ keyboard: z.array(z.enum(['app', 'window'])), sessionLocked: z.boolean().nullable(), diff --git a/packages/experimental/cua/src/session-permissions.ts b/packages/experimental/cua/src/session-permissions.ts deleted file mode 100644 index f21a61b685..0000000000 --- a/packages/experimental/cua/src/session-permissions.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Provider-owned permissions remembered separately for each live Session and requested capability. */ -import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' -import { applicationAccessDisplay } from './locales.ts' - -type Localized = { en: string; [locale: string]: string } - -/** A provider owns the lifetime and names of the permissions it requests. */ -export class CuaSessionPermissions { - private readonly decisions = new WeakMap>() - - constructor(private readonly ctx: Context) {} - - /** - * Remember the user's choice only for this permission in this live Session. - * @param agent Live Session owner. - * @param permission Provider-defined permission name shared by calls requesting the same access. - * @param prompt Localized question and scope displayed before the user decides. - * @param signal Calling operation lifetime. Aborting or dismissing the question leaves the permission undecided. - * @returns True only when the user selects session approval. - */ - async request(agent: Agent, permission: string, prompt: { question: Localized; detail: Localized }, - signal: AbortSignal): Promise { - signal.throwIfAborted() - const decisions = this.decisions.get(agent) ?? new Map() - const decision = decisions.get(permission) - if (decision === 'asking') throw new UserQuestionError('Wait for the outstanding permission request', 'APPROVAL_PENDING') - if (decision !== undefined) return decision - const questions = this.ctx.get('userQuestions') - if (!questions) throw new UserQuestionError('No human answerer is available for the permission request', 'NO_PROVIDER') - this.decisions.set(agent, decisions) - decisions.set(permission, 'asking') - const id = `cua-session-permission:${permission}` - const display = { ...prompt, approve: applicationAccessDisplay.approve } - const approve = display.approve.en - const reject = 'Deny' - try { - const result = await questions.ask({ agent, signal, questions: [{ - id, question: prompt.question.en, detail: prompt.detail.en, options: [{ label: approve }, { label: reject }], - intent: { kind: 'approval', approve, reject, display }, - }] }) - signal.throwIfAborted() - const [answer] = result.answers - const granted = result.answers.length === 1 && answer?.id === id && !answer.custom?.trim() - && answer.selected.length === 1 && answer.selected[0] === approve - decisions.set(permission, granted) - return granted - } catch (error) { - decisions.delete(permission) - throw error - } - } -} diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index b84663f9e2..98c7c7a1a2 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -2,7 +2,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { z } from 'zod' -import type { ActionResult, CapabilitiesResult, FoundAppsResult, HoverResult, Request, ScreenshotResult, ShowResult, WindowsResult } from './protocol.ts' +import type { ActionResult, CapabilitiesResult, FoundAppsResult, HoverResult, SystemPointerRequest, ScreenshotResult, ShowResult, WindowsResult } from './protocol.ts' /** Helper-issued application selection; public app objects hold only this opaque handle. */ export type CuaAppHandle = Branded<'CuaAppHandle'> @@ -11,7 +11,7 @@ export type CuaSnapshotId = Branded<'CuaSnapshotId'> /** Opaque string defined by the platform provider, valid for at least the lifetime of one snapshot. */ export type CuaElementId = Branded<'CuaElementId'> /** One operation sent through an authorized and logged host dispatcher. */ -export type CuaOperation = z.output +export type CuaOperation = z.output type CuaParameters = Extract['params'] /** Dispatch exactly once; a failed action must not be retried implicitly. */ export type CuaDispatch = (operation: CuaOperation) => Promise @@ -49,7 +49,7 @@ export type CuaCapabilities = z.output export type CuaStartupCapabilities = Pick< CuaCapabilities, 'platform' | 'methods' | 'appLevelAccessibility' | 'keyboard' | 'browser' | 'coordinateSpace' -> +> & { systemPointer?: boolean } /** App discovery result accepted by getApp. */ export type CuaAppInfo = Pick, 'id' | 'displayName' | 'isRunning' | 'pid'> /** An installed application match; running matches include a process identifier. */ @@ -196,10 +196,12 @@ export interface CuaWindow extends CuaSurface { * Returning and subsequent observations do not restore the previous pointer position. * Other input and application changes can interrupt the interaction; observe before continuing. * @param target - current element id or screenshot-local [x,y]. - * @param options - coordinate mode; applies only to [x,y]. + * @param options - Coordinate mode for points. Only providers reporting systemPointer accept useSystemPointer. + * True requests physical movement for this call only. * @returns dispatch evidence and the selected route; observe the resulting UI after any UI delay. */ - mouseMove(target: CuaElementId | readonly [number, number], options?: CuaCoordinateOptions): Promise + mouseMove(target: CuaElementId | readonly [number, number], + options?: CuaCoordinateOptions & { useSystemPointer?: boolean }): Promise /** * Click a current element id or screenshot-local point. * Cancellation or timeout releases the held button; received input cannot be undone. The operation deadline includes holdMs. diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index 73dbf044cc..9b9a58c64c 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -13,7 +13,7 @@ const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG const bounds = { x: -640, y: 100, width: 640, height: 480 } const selected = { appHandle: 'app-17', pid: 17, name: 'Editor', bundleId: 'test.editor', running: true } const delivered = { dispatched: true } -const capabilities: CuaCapabilities = { platform: 'macos', sessionLocked: false, permissions: { accessibility: true, screenRecording: false }, appLevelAccessibility: true, keyboard: ['app'], methods: ['getState', 'click'], coordinateSpace: 'screenshot-pixels', browser: false } +const capabilities: CuaCapabilities = { platform: 'macos', systemPointer: true, sessionLocked: false, permissions: { accessibility: true, screenRecording: false }, appLevelAccessibility: true, keyboard: ['app'], methods: ['getState', 'click'], coordinateSpace: 'screenshot-pixels', browser: false } function windowInfo(windowId: number) { return { windowId, appName: 'Editor', title: `Document ${windowId}`, bounds, isOnScreen: true, onCurrentSpace: null } diff --git a/packages/experimental/cua/tests/application-approval.client.spec.tsx b/packages/experimental/cua/tests/application-approval.client.spec.tsx index 48f4cadafd..13e79b8faa 100644 --- a/packages/experimental/cua/tests/application-approval.client.spec.tsx +++ b/packages/experimental/cua/tests/application-approval.client.spec.tsx @@ -91,7 +91,7 @@ it('shows the mouse interruption and expiry with only session approval and refus detail: { en: 'Until restart', zh: '重启后失效' }, approve: { en: 'Allow for this session', zh: '本会话允许' }, } - const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-session-permission:example', question: display.question.en, + const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-mouse-control', question: display.question.en, detail: display.detail.en, intent: { kind: 'approval', approve: display.approve.en, reject: 'Deny', display } }]) const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null) render( brandString(id) const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC' const capabilities: CuaCapabilities = { - platform: 'macos', permissions: { accessibility: true, screenRecording: true }, appLevelAccessibility: true, + platform: 'macos', systemPointer: true, permissions: { accessibility: true, screenRecording: true }, appLevelAccessibility: true, keyboard: ['app'], sessionLocked: false, methods: ['getCapabilities', 'getApp', 'getState', 'screenshot'], coordinateSpace: 'screenshot-pixels', browser: false, } diff --git a/packages/experimental/cua/tests/fixtures/exports.mjs b/packages/experimental/cua/tests/fixtures/exports.mjs index 91b55b76b8..19dbd3973e 100644 --- a/packages/experimental/cua/tests/fixtures/exports.mjs +++ b/packages/experimental/cua/tests/fixtures/exports.mjs @@ -12,8 +12,6 @@ const guard = registerHooks({ }) const { Context } = await import('@deepseek-ai/cordis') const host = await import('@deepseek-ai/dsh-cua') -const { CuaSessionPermissions } = await import('@deepseek-ai/dsh-cua/session-permissions') -assert.equal(typeof CuaSessionPermissions.prototype.request, 'function') const { createCuaApi } = await import('@deepseek-ai/dsh-cua/api') const { CuaConfigFields } = await import('@deepseek-ai/dsh-cua/config') assert.equal(typeof CuaConfigFields.connectTimeoutMs.default, 'function') diff --git a/packages/experimental/cua/tests/protocol.spec.ts b/packages/experimental/cua/tests/protocol.spec.ts index 50fb329c07..1842ac1f78 100644 --- a/packages/experimental/cua/tests/protocol.spec.ts +++ b/packages/experimental/cua/tests/protocol.spec.ts @@ -179,7 +179,7 @@ it.each([true, false])('requires the reported application-level accessibility ca platform: 'macos', permissions: { accessibility: true, screenRecording: false }, appLevelAccessibility, keyboard: ['app'], sessionLocked: false, methods: [], coordinateSpace: 'screenshot-pixels', browser: false, } - expect(CapabilitiesResult.parse(capabilities)).toEqual(capabilities) + expect(CapabilitiesResult.parse(capabilities)).toEqual({ ...capabilities, systemPointer: false }) const { appLevelAccessibility: _omitted, ...missing } = capabilities expect(CapabilitiesResult.safeParse(missing).success).toBe(false) }) @@ -195,7 +195,7 @@ it.each([ platform: 'windows', permissions: { accessibility: true, screenRecording: true }, appLevelAccessibility: false, keyboard, sessionLocked: false, methods: [], coordinateSpace: 'screenshot-pixels', browser: false, } - expect(CapabilitiesResult.parse(capabilities)).toEqual(capabilities) + expect(CapabilitiesResult.parse(capabilities)).toEqual({ ...capabilities, systemPointer: false }) }) it.each([ diff --git a/packages/experimental/cua/tsdown.config.ts b/packages/experimental/cua/tsdown.config.ts index 92c9eca9a8..29526fddd6 100644 --- a/packages/experimental/cua/tsdown.config.ts +++ b/packages/experimental/cua/tsdown.config.ts @@ -3,7 +3,6 @@ import { clientBundle } from '../../client/tsdown.client.ts' const runtime: UserConfig[] = Object.entries({ 'remote-types': 'lib/types/remote-types.js', api: 'lib/types/api.js', bridge: 'lib/types/bridge.js', - 'session-permissions': 'lib/types/session-permissions.js', 'native-client': 'lib/types/native-client.js', protocol: 'lib/types/protocol.js', config: 'lib/types/config.js', 'native-schema': 'lib/types/native-schema.js', 'native-build': 'lib/types/native-build.js', guidance: 'lib/types/guidance.js', }).map(([name, path]) => ({ diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 8e75b08fbd..f759eca7ec 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -188,7 +188,7 @@ const packageFileExtras: Readonly> = { // The embedded OAR implementation retains its upstream license. '@deepseek-ai/dsh-experimental-subagent-oar': ['LICENSE-OAR'], // CUA publishes its client API and attributed native source for local macOS builds. - '@deepseek-ai/dsh-cua': ['lib/remote-types.js', 'lib/api.js', 'lib/bridge.js', 'lib/native-client.js', 'lib/session-permissions.js', 'lib/protocol.js', 'lib/config.js', 'lib/native-schema.js', 'lib/native-build.js', 'lib/guidance.js'], + '@deepseek-ai/dsh-cua': ['lib/remote-types.js', 'lib/api.js', 'lib/bridge.js', 'lib/native-client.js', 'lib/protocol.js', 'lib/config.js', 'lib/native-schema.js', 'lib/native-build.js', 'lib/guidance.js'], '@deepseek-ai/dsh-experimental-cua-macos': ['native/bin', 'cua-helper.json', 'native/Package.swift', 'native/Info.plist', 'native/DSHComputerUse.icns', 'native/build_app.py', 'native/Sources', 'native/tests/swift', 'native/tests/test_swift.py', 'native/LICENSE.cua-driver', 'native/LICENSE.computer-use-icon', 'native/upstream.json', 'native/README.md', 'native/README.zh.md', 'native/protocol.schema.json'], '@deepseek-ai/dsh-webhook-github': ['examples/github-review/cordis.yml', 'examples/github-review/github-ready-review-rule.mjs'], // Liveshare publishes its built Relay gateway entry and the shared runtime diff --git a/tsconfig.base.json b/tsconfig.base.json index 58c5ce6e40..e8ca4f8e44 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -113,7 +113,6 @@ "@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp": ["./packages/experimental/computer-use-cua-driver-mcp/src/index.ts"], "@deepseek-ai/dsh-experimental-cua-macos": ["./packages/experimental/cua-macos/src/index.ts"], "@deepseek-ai/dsh-cua/config": ["./packages/experimental/cua/src/config.ts"], - "@deepseek-ai/dsh-cua/session-permissions": ["./packages/experimental/cua/src/session-permissions.ts"], "@deepseek-ai/dsh-cua/native-schema": ["./packages/experimental/cua/src/native-schema.ts"], "@deepseek-ai/dsh-cua/native-build": ["./packages/experimental/cua/src/native-build.ts"], "@deepseek-ai/dsh-cua/guidance": ["./packages/experimental/cua/src/guidance.ts"], From cbba0c66a970ee88484dd0f3603a41f28acc52c9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sat, 7 Nov 2026 18:20:34 +0800 Subject: [PATCH 62/81] test(cua): verify pointer capabilities and portable option forwarding --- packages/experimental/cua-macos/README.md | 2 +- packages/experimental/cua-macos/README.zh.md | 2 +- packages/experimental/cua-macos/src/guidance.ts | 2 +- .../cua-macos/tests/protocol-generation.spec.ts | 11 +---------- .../cua-macos/tests/provider-lifecycle.spec.ts | 2 +- packages/experimental/cua/src/guidance.ts | 2 +- packages/experimental/cua/src/protocol.ts | 2 +- packages/experimental/cua/tests/api.spec.ts | 4 ++-- packages/experimental/cua/tests/protocol.spec.ts | 11 ++++++++++- snapshots/session/cua-native/session.v4.jsonl | 2 +- .../session/cua-native/system-prompt.expected.md | 6 ++++-- snapshots/session/cua-ptc/system-prompt.expected.md | 6 ++++-- .../ptc-script-retained/system-prompt.expected.md | 4 ++-- 13 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 7b5df96219..e20c639e4c 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -228,7 +228,7 @@ Available at app or window level depending on the platform. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. -Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. ``` #### Token effect diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 8db5bee329..83177e6047 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -228,7 +228,7 @@ Available at app or window level depending on the platform. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. -Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. ``` #### Token 影响 diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index 0428941edb..68243fdaab 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -10,7 +10,7 @@ const methodDetails = new Map([ ['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'], ]) -/** Complete model instructions for this provider. */ +/** Native operation instructions; the shared Host adds guidance for declared capabilities. */ export const GUIDANCE = `${PLATFORM_GUIDANCE} ${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => { diff --git a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts index c83c16e77e..36c6dd94c9 100644 --- a/packages/experimental/cua-macos/tests/protocol-generation.spec.ts +++ b/packages/experimental/cua-macos/tests/protocol-generation.spec.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises' import { Ajv2020 } from 'ajv/dist/2020.js' import { describe, expect, it } from 'vitest' import { renderNativeProtocol } from '../native/generate-protocol.ts' -import { Request, SystemPointerRequest } from '@deepseek-ai/dsh-cua/protocol' +import { Request } from '@deepseek-ai/dsh-cua/protocol' describe('native protocol generation', () => { it('keeps native JSON Schema and Swift admission tables current', async () => { @@ -28,15 +28,6 @@ describe('native protocol generation', () => { expect(validate({ id: 'shortcut', ...request })).toBe(false) }) - it('admits the pointer option only in the system-pointer model schema and only as a boolean', () => { - const target = { appHandle: 'app', windowId: 1, x: 0, y: 0 } - const request = { method: 'mouseMove', params: { ...target, useSystemPointer: true } } - expect(Request.safeParse(request).success).toBe(false) - expect(SystemPointerRequest.safeParse(request).success).toBe(true) - expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, useSystemPointer: 'true' } }).success).toBe(false) - expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, arbitraryField: true } }).success).toBe(false) - }) - it.each([ { method: 'getState', params: { appHandle: 'app', maxElements: 1 } }, { method: 'drag', params: { appHandle: 'app', windowId: 1, fromX: 1, fromY: 1, toX: 2, toY: 2, steps: 1 } }, diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index 343bbbdb33..5e7b5893f6 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -1135,7 +1135,7 @@ it.each([true, false])('uses the declared systemPointer capability for a non-mac expect((await f.execute('getCapabilities')).value).toMatchObject({ platform: 'fixture', systemPointer }) const sections = (await f.ctx.systemPrompt.assemble({ scope: f.handle.agent })).sections const guidance = sections.find(section => section.name === 'computer-use:dsh-cua')!.text - expect(guidance.includes('useSystemPointer')).toBe(systemPointer) + expect(guidance.includes('relative?: boolean, useSystemPointer?: boolean')).toBe(systemPointer) expect(guidance.includes('The question interrupts the user')).toBe(systemPointer) expect(JSON.stringify(f.ctx.tools.get('cua', f.handle.agent)!.parameters).includes('useSystemPointer')).toBe(systemPointer) await selectApplication(f) diff --git a/packages/experimental/cua/src/guidance.ts b/packages/experimental/cua/src/guidance.ts index 5e3f5cfc95..49caa08bf2 100644 --- a/packages/experimental/cua/src/guidance.ts +++ b/packages/experimental/cua/src/guidance.ts @@ -58,5 +58,5 @@ export function withSystemPointerGuidance(guidance: string): string { const signature = 'mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean })' return `${guidance.replace(hoverSignature, signature)} -Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement.` +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement.` } diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index 7684bb4aca..3bedc404d1 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -83,7 +83,7 @@ function requestSchema(hover: T) { ]) } -/** Portable model operations, including auxiliary hover without a system-pointer option. */ +/** Portable model operations, including standard hover without a system-pointer option. */ export const Request = requestSchema(z.union(MouseMoveTargets)) /** Model operations for providers that can move the user's system pointer. */ diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index 9b9a58c64c..f71b958368 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -901,10 +901,10 @@ it('forwards provider options while retaining the selected target and coordinate appHandle: 'app-17', windowId: 23, snapshotId: 'snapshot-2', elementId: '0', trackingMode: 'precise', } }) await win.getScreenshot({ emit: false }) - const pixelOptions = { relative: false, trackingMode: 'precise', x: 222 } + const pixelOptions = { relative: false, useSystemPointer: true, trackingMode: 'precise', x: 222 } await win.mouseMove([30, 40], pixelOptions) expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'mouseMove', params: { - appHandle: 'app-17', snapshotId: 'snapshot-2', windowId: 23, x: 30, y: 40, relative: false, trackingMode: 'precise', + appHandle: 'app-17', snapshotId: 'snapshot-2', windowId: 23, x: 30, y: 40, relative: false, useSystemPointer: true, trackingMode: 'precise', } }) const relativeOptions = { relative: true, trackingMode: 'coarse' } await win.mouseMove([250, 750], relativeOptions) diff --git a/packages/experimental/cua/tests/protocol.spec.ts b/packages/experimental/cua/tests/protocol.spec.ts index 1842ac1f78..f259df5fcd 100644 --- a/packages/experimental/cua/tests/protocol.spec.ts +++ b/packages/experimental/cua/tests/protocol.spec.ts @@ -1,7 +1,7 @@ /** Model-authored operation JSON cannot supply host configuration or bypass retained identities. */ import { expect, it } from 'vitest' import type { JsonValue } from '@deepseek-ai/dsh-util-values' -import { CapabilitiesResult, Request, ScreenshotResult } from '../src/protocol.ts' +import { CapabilitiesResult, Request, ScreenshotResult, SystemPointerRequest } from '../src/protocol.ts' const rejectedRequests: Array<{ reason: string; request: JsonValue }> = [ { reason: 'raw process ID instead of a selected app handle', request: { method: 'typeText', params: { pid: 17, text: 'hello' } } }, @@ -217,3 +217,12 @@ it('requires keyboard scopes', () => { } expect(CapabilitiesResult.safeParse(capabilities).success).toBe(false) }) + +it('admits the pointer option only in the system-pointer model schema and only as a boolean', () => { + const target = { appHandle: 'app', windowId: 1, x: 0, y: 0 } + const request = { method: 'mouseMove', params: { ...target, useSystemPointer: true } } + expect(Request.safeParse(request).success).toBe(false) + expect(SystemPointerRequest.safeParse(request).success).toBe(true) + expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, useSystemPointer: 'true' } }).success).toBe(false) + expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, arbitraryField: true } }).success).toBe(false) +}) diff --git a/snapshots/session/cua-native/session.v4.jsonl b/snapshots/session/cua-native/session.v4.jsonl index 7482438890..db5e234368 100644 --- a/snapshots/session/cua-native/session.v4.jsonl +++ b/snapshots/session/cua-native/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect CUA capabilities, select the","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889225,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889225,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889225,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cua-native-1","name":"cua","arguments":"{\"method\":\"getCapabilities\",\"params\":{}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"sendKeyEvent\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cua-native-1"},"toolCallId":"cua-native-1","content":[{"type":"text","text":"{\"sessionLocked\":false,\"permissions\":{\"accessibility\":true,\"screenRecording\":true},\"platform\":\"macos\",\"systemPointer\":true,\"methods\":[\"activate\",\"click\",\"drag\",\"findApps\",\"getApp\",\"getCapabilities\",\"getState\",\"invoke\",\"listActions\",\"listApps\",\"listWindows\",\"mouseMove\",\"paste\",\"performAction\",\"pressKey\",\"screenshot\",\"scroll\",\"selectText\",\"sendKeyEvent\",\"setValue\",\"show\",\"typeText\"],\"appLevelAccessibility\":true,\"keyboard\":[\"app\"],\"browser\":false,\"coordinateSpace\":\"screenshot-pixels\"}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1794032889794,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1794032889794,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cua-unauthorized-getState","name":"cua","arguments":"{\"method\":\"getState\",\"params\":{\"appHandle\":\"fixture-app\"}}"}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1794032889794,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index a663884957..9589c20d8e 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -42,7 +42,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -67,7 +67,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -83,4 +83,6 @@ Available at app or window level depending on the platform. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. + Maximum click holdMs: 5000 ms. diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 9b9d570d03..6888ad0481 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -44,7 +44,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -69,7 +69,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. @@ -85,6 +85,8 @@ Available at app or window level depending on the platform. Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name. +Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement. + Maximum click holdMs: 5000 ms. ## Writing code for run_code diff --git a/snapshots/session/ptc-script-retained/system-prompt.expected.md b/snapshots/session/ptc-script-retained/system-prompt.expected.md index e2fb062dc6..c670f443b9 100644 --- a/snapshots/session/ptc-script-retained/system-prompt.expected.md +++ b/snapshots/session/ptc-script-retained/system-prompt.expected.md @@ -44,7 +44,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid. Setup and discovery: -- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. +- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions. - cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications. - cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid. - cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation. @@ -69,7 +69,7 @@ Element ids are opaque strings from the latest successful tree; the object facad ## Simulated mouse Use an explicit window for screenshots and pointer input. - getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG. -- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. HOVER_UNAVAILABLE sends no input and never opens a question. Use an advertised accessibility action, click a visible control, or use browser tools for web content whenever possible. Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). The question interrupts the user. Approval moves their pointer to this target. Pass useSystemPointer: true on every physical move, even after approval. Omit it or pass false for AX hover. Further explicit moves reuse approval until the session closes or DSH restarts. A refusal sends no input; continue with pointer-free actions. If the question is dismissed, ask again only when the user requests it. Await an outstanding request before making another. With requireSystemPointerApproval disabled, explicit moves do not prompt. Subagents cannot request physical moves. System movement requires an unobscured foreground target; explain any necessary activation and observe the target again. +- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again. - click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying. - scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left. - drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window. From d6208abfcaff5526a1c623ff4d54ed9e4bb73a59 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 7 Nov 2026 18:21:08 +0800 Subject: [PATCH 63/81] docs(forge-github): state how review reads bound the first page and which pages re-check the head A first review thread page whose output alone passes maxOutputBytes fails the read as too-large through gh's output limit, rather than ending it as truncated as the README and the ReviewLimits documentation said; only a later page that would pass the limit truncates the read. Thread pages after the first re-check the head, while a thread's comment pages do not. A test pins the first-page failure. --- packages/experimental/forge-github/README.i18n.yaml | 4 ++-- packages/experimental/forge-github/README.md | 2 +- packages/experimental/forge-github/README.zh.md | 2 +- packages/experimental/forge-github/src/threads.ts | 10 +++++++--- .../experimental/forge-github/tests/threads.spec.ts | 8 +++++++- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index 6d3ca2d441..a12b42da43 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: dc1fc7eb436482c8 - zh: 968aa86136fdd840 + en: e01228c710824a6c + zh: d59f5f14f7e8150f /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: en: f3d0a577b0a4db0a zh: 0c15ac7979909117 diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 0d5f8ea6b6..437f23b2ec 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -50,7 +50,7 @@ A pull request is `mergeReady` when it is open, not a draft, GitHub reports its Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints. -`readReview` reads review threads with one GraphQL query that names `headRefOid` and the signed-in viewer, and returns `stale` when the head is not the requested commit. It reads threads in pages of up to 100 until it has read `maxReviewThreads` of them, each page after the first naming the head again, so a push during the read returns `stale`; it reads every comment of each thread, and the commits and `diffHunk` of a thread's first comment once. All pages together, measured as JSON, must fit `maxOutputBytes`: the page that would pass it is dropped with the threads after it, and the result is marked `truncated`, as when more than `maxReviewThreads` threads exist. A thread on lines gets a `current` position only when GitHub reports it not outdated, with a `line`, and its first comment's `commit` is the head: a comment written on an earlier commit after a force-push keeps a `line` that numbers that commit, and GitHub can mark a thread outdated while keeping its `line`. `original` comes from `originalLine` and `originalStartLine` at the first comment's `originalCommit`; `subjectType: FILE` threads have neither. A range whose start is on the other side of the diff keeps its last line. Threads without comments are left out. REST's `position` does not tell outdated threads apart and is not read. +`readReview` reads review threads with one GraphQL query that names `headRefOid` and the signed-in viewer, and returns `stale` when the head is not the requested commit. It reads threads in pages of up to 100 until it has read `maxReviewThreads` of them, each thread page after the first naming the head again, so a push during the read returns `stale`; it reads every comment of each thread through comment pages, which do not name the head, and the commits and `diffHunk` of a thread's first comment once. All pages together, measured as JSON, must fit `maxOutputBytes`: a later page that would pass it is dropped with the threads after it, and the result is marked `truncated`, as when more than `maxReviewThreads` threads exist; a first page whose output alone passes it fails the read as `too-large`. A thread on lines gets a `current` position only when GitHub reports it not outdated, with a `line`, and its first comment's `commit` is the head: a comment written on an earlier commit after a force-push keeps a `line` that numbers that commit, and GitHub can mark a thread outdated while keeping its `line`. `original` comes from `originalLine` and `originalStartLine` at the first comment's `originalCommit`; `subjectType: FILE` threads have neither. A range whose start is on the other side of the diff keeps its last line. Threads without comments are left out. REST's `position` does not tell outdated threads apart and is not read. ----- diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 99896c73d8..2f94f93d75 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -50,7 +50,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。 -`readReview` 用一次 GraphQL 查询读取评审讨论,同时读出 `headRefOid` 和当前登录的用户;head 不是所请求的提交时返回 `stale`。讨论按每页最多 100 个分页读取,直到读满 `maxReviewThreads` 个;第一页之后的每一页都会再次读取 head,因此读取期间有新的推送时返回 `stale`。每个讨论的评论都会全部读出,讨论第一条评论的提交和 `diffHunk` 只读一次。所有页面按 JSON 计合计不得超过 `maxOutputBytes`:会超出上限的那一页及其后的讨论被丢弃,结果标记为 `truncated`,与讨论数超过 `maxReviewThreads` 时相同。行级讨论只有在 GitHub 报告它未过期、带有 `line`,且第一条评论的 `commit` 就是 head 时,才得到 `current` 位置:force-push 之后写在旧提交上的评论保留的 `line` 是那个旧提交的行号,GitHub 也可能在标记讨论过期的同时保留它的 `line`。`original` 取自第一条评论 `originalCommit` 上的 `originalLine` 和 `originalStartLine`;`subjectType: FILE` 的讨论两者都没有。起始行在 diff 另一侧的范围只保留最后一行。没有评论的讨论会被略去。REST 的 `position` 无法区分已过期的讨论,因此不读取。 +`readReview` 用一次 GraphQL 查询读取评审讨论,同时读出 `headRefOid` 和当前登录的用户;head 不是所请求的提交时返回 `stale`。讨论按每页最多 100 个分页读取,直到读满 `maxReviewThreads` 个;第一页之后的每一页讨论都会再次读取 head,因此读取期间有新的推送时返回 `stale`。每个讨论的评论都会经评论分页全部读出,评论分页不读取 head;讨论第一条评论的提交和 `diffHunk` 只读一次。所有页面按 JSON 计合计不得超过 `maxOutputBytes`:之后的某一页会超出上限时,该页及其后的讨论被丢弃,结果标记为 `truncated`,与讨论数超过 `maxReviewThreads` 时相同;第一页的输出本身超过上限时,读取以 `too-large` 失败。行级讨论只有在 GitHub 报告它未过期、带有 `line`,且第一条评论的 `commit` 就是 head 时,才得到 `current` 位置:force-push 之后写在旧提交上的评论保留的 `line` 是那个旧提交的行号,GitHub 也可能在标记讨论过期的同时保留它的 `line`。`original` 取自第一条评论 `originalCommit` 上的 `originalLine` 和 `originalStartLine`;`subjectType: FILE` 的讨论两者都没有。起始行在 diff 另一侧的范围只保留最后一行。没有评论的讨论会被略去。REST 的 `position` 无法区分已过期的讨论,因此不读取。 ----- diff --git a/packages/experimental/forge-github/src/threads.ts b/packages/experimental/forge-github/src/threads.ts index f23507e374..5717f47a0c 100644 --- a/packages/experimental/forge-github/src/threads.ts +++ b/packages/experimental/forge-github/src/threads.ts @@ -45,7 +45,10 @@ export const THREAD_COMMENTS_QUERY = `query($id: ID!, $cursor: String!) { export interface ReviewLimits { /** Most threads read. */ readonly maxReviewThreads: number - /** Most JSON bytes of all pages together; a page that would pass it ends the read as truncated. */ + /** + * Most JSON bytes of all pages together: a later page that would pass it ends the read as truncated, and a first + * page whose output alone passes it fails the read as `too-large`. + */ readonly maxOutputBytes: number } @@ -97,8 +100,9 @@ type CommentNode = z.infer /** * Read the review threads of a pull request while its head is `headSha`. The first page also names the head and the * signed-in user; every following thread page names the head again, so a head that moves during the read returns - * `stale`. Threads are read up to `maxReviewThreads`, each with all of its comments. A page that would bring the - * pages read, measured as JSON, past `maxOutputBytes` is dropped with the threads after it, and the result is marked truncated. + * `stale`, while a thread's comment pages do not. Threads are read up to `maxReviewThreads`, each with all of its + * comments. A later page that would bring the pages read, measured as JSON, past `maxOutputBytes` is dropped with the + * threads after it, and the result is marked truncated; `read` fails a first page whose output alone passes the limit. * @param read - runs one GraphQL request. * @param ref - pull request, with an owner and name already checked for inlining. * @param headSha - head commit the threads must be read at. diff --git a/packages/experimental/forge-github/tests/threads.spec.ts b/packages/experimental/forge-github/tests/threads.spec.ts index 7962e90280..42c49f389a 100644 --- a/packages/experimental/forge-github/tests/threads.spec.ts +++ b/packages/experimental/forge-github/tests/threads.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GhOutput, GhRunner } from '../src/gh.ts' +import { GhOutputLimitError, type GhOutput, type GhRunner } from '../src/gh.ts' import { GitHubProvider } from '../src/provider.ts' import type { GitHubLimits } from '../src/provider.ts' import { REVIEW_QUERY, REVIEW_THREADS_QUERY, THREAD_COMMENTS_QUERY } from '../src/threads.ts' @@ -206,6 +206,12 @@ describe('GitHub provider review threads', () => { .resolves.toMatchObject({ threads: [{ id: 't1' }], truncated: true }) }) + it('fails a read whose first page alone passes maxOutputBytes as too-large', async () => { + const run: GhRunner = async () => { throw new GhOutputLimitError('gh output exceeds maxOutputBytes') } + + await expect(provider(run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'too-large', message: 'gh output exceeds maxOutputBytes' }) + }) + it.each([ ['a thread page without a cursor', firstPage({ pageInfo: { hasNextPage: true, endCursor: null }, nodes: [] }), {}, {}, 'GitHub review thread pagination did not advance'], From bafc363d780705ec674abb95eb93917f7d9dbb23 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 9 Nov 2026 10:59:09 +0800 Subject: [PATCH 64/81] fix(worktree): reclaim archived checkouts in large repositories Archive cleanup read whole Git listings into the 64 KiB maxOutputBytes buffer, so every clean checkout in a repository with more than about a thousand tracked files settled as failed. - Drop the skip-worktree/assume-unchanged and gitlink listings; removal follows what `git status` reports, as peer agent products do. - Stream `git status` and stop at its first entry, and stream the worktree listing, so neither is bounded by maxOutputBytes; restoring a worktree shares the streamed lookup. - Retain a checkout whose administrative directory holds submodule repositories instead of letting Git refuse the removal. Refs #8151 --- docs/config-catalog.md | 7 +- docs/config-catalog.zh.md | 7 +- .../archive-worktrees/guide.i18n.yaml | 8 +- .../v0.2.2-rc.1/archive-worktrees/guide.md | 4 +- .../v0.2.2-rc.1/archive-worktrees/guide.zh.md | 4 +- .../experimental/worktree/README.i18n.yaml | 4 +- packages/experimental/worktree/README.md | 2 +- packages/experimental/worktree/README.zh.md | 2 +- packages/experimental/worktree/src/index.ts | 32 +++-- packages/experimental/worktree/src/process.ts | 123 ++++++++++++++---- packages/experimental/worktree/src/removal.ts | 81 +++++++----- .../worktree/tests/archive.spec.ts | 38 +++++- .../worktree/tests/removal.spec.ts | 54 ++++++-- .../worktree/tests/worktree.spec.ts | 16 +++ 14 files changed, 290 insertions(+), 92 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d6284aee3c..678d7387d2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1561,7 +1561,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/experimental/worktree/src/index.ts:35`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:36`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, subprocess resource limits, and the archive cleanup switch. */ @@ -1580,7 +1580,10 @@ export interface Config { timeoutMs?: number /** Subprocess termination grace in milliseconds. */ graceMs?: number - /** Maximum captured bytes per subprocess output stream. */ + /** + * Maximum captured bytes per subprocess output stream; archive inspection + * streams `git status` and the worktree listing, so it bounds only their stderr. + */ maxOutputBytes?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 70e75f8142..b1707d7c32 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1563,7 +1563,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/experimental/worktree/src/index.ts:35`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:36`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, subprocess resource limits, and the archive cleanup switch. */ @@ -1582,7 +1582,10 @@ export interface Config { timeoutMs?: number /** Subprocess termination grace in milliseconds. */ graceMs?: number - /** Maximum captured bytes per subprocess output stream. */ + /** + * Maximum captured bytes per subprocess output stream; archive inspection + * streams `git status` and the worktree listing, so it bounds only their stderr. + */ maxOutputBytes?: number } ``` diff --git a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml index b563eb7323..c17fd006c0 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml @@ -9,8 +9,8 @@ en: bc309764ab7b8b98 zh: 20fdfbce76706025 /worktree-cleanup-on-archive/change: - en: 05f27e166f2dbb40 - zh: a320ac6252fd2e38 + en: c16e3db7ee76eee8 + zh: a1db1e346d5af41a /worktree-cleanup-on-archive/migration: - en: c9b2fca77d02e07f - zh: a4228d7f22fb7e35 + en: 05f29b760cb99ae9 + zh: 33f181ac2a582bdf diff --git a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md index 77912a2e71..9232c6ecdd 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md @@ -9,13 +9,13 @@ English | [中文](guide.zh.md) ## Change -Archiving a Session previously retained its checkout. The worktree service now records each checkout it creates as a `plugin:worktree/created` record and, when the Session that created one is archived, removes it with a non-force `git worktree remove` when no unarchived Session refers to it, its creator's policy is not read-only, and Git finds no tracked changes, untracked files, hidden index flags, submodules, locks, or detached HEAD. Ignored files such as `node_modules` do not retain it. Branches and commits remain. The service's `retainOnArchive` setting, off by default, keeps every checkout instead. Checkouts created before this release have no such event and are never swept, nor are Sessions archived earlier. See the [worktree service](../../../../packages/experimental/worktree/README.md). +Archiving a Session previously retained its checkout. The worktree service now records each checkout it creates as a `plugin:worktree/created` record and, when the Session that created one is archived, removes it with a non-force `git worktree remove` when no unarchived Session refers to it, its creator's policy is not read-only, and Git finds no tracked changes, untracked files, submodule repositories, locks, or detached HEAD. Ignored files such as `node_modules` and edits to skip-worktree or assume-unchanged files, which `git status` hides, do not retain it; Git discards them. Branches and commits remain. The service's `retainOnArchive` setting, off by default, keeps every checkout instead. Checkouts created before this release have no such event and are never swept, nor are Sessions archived earlier. See the [worktree service](../../../../packages/experimental/worktree/README.md). The Forge bundle mounts one `worktree` service for both the panel and the `create_worktree` tool. The `forge-worktree` row and the `optional-forge-panel` group that isolated a second service are gone; the panel is the top-level `forge-panel` row. ## Migration -1. If a checkout must remain available outside DSH, keep its Session unarchived, lock it with `git worktree lock ` before archiving, or turn on **Keep worktrees when archiving** on the Forge bundle's worktree row of the Plugins page (`retainOnArchive`), which keeps every checkout. Ignored files are not kept. +1. If a checkout must remain available outside DSH, keep its Session unarchived, lock it with `git worktree lock ` before archiving, or turn on **Keep worktrees when archiving** on the Forge bundle's worktree row of the Plugins page (`retainOnArchive`), which keeps every checkout. Ignored files are not kept, nor are edits to skip-worktree or assume-unchanged files: commit those edits, or clear the flags with `git update-index --no-skip-worktree` or `--no-assume-unchanged`, before archiving. 2. To resume an archived Session whose checkout was removed, unarchive it and use **Restore worktree**. The retained branch is checked out at the recorded path. 3. In a profile's `cordis.patch.yml`, move any `forge-worktree` override to the `worktree` row and any `optional-forge-panel` override to the `forge-panel` row; a `disabled: true` on `forge-panel` turns off the panel alone. 4. Confirm the branch and commits are present with `git branch --list` and that **Restore worktree** makes the recorded checkout available. Session logs require no migration. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md index 5edf966298..0d32dded1c 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md @@ -9,13 +9,13 @@ description: "归档 Session 可以删除其干净的工作树并保留分支, ## 变更 -归档 Session 以前会保留检出目录。工作树服务现在把它创建的每个检出目录记录为 `plugin:worktree/created` Session 事件;当创建它的 Session 被归档时,若没有未归档的 Session 引用该目录、创建者的策略不是只读,且 Git 未发现跟踪文件改动、未跟踪文件、隐藏的索引标记、子模块、锁或分离 HEAD,就用不带 force 的 `git worktree remove` 删除它。`node_modules` 之类被忽略的文件不会保留目录。分支和提交仍会保留。服务的 `retainOnArchive` 设置默认关闭,开启后改为保留所有检出目录。本版本之前创建的检出目录没有该事件,不会被扫描;更早归档的 Session 也不会。参见[工作树服务](../../../../packages/experimental/worktree/README.zh.md)。 +归档 Session 以前会保留检出目录。工作树服务现在把它创建的每个检出目录记录为 `plugin:worktree/created` Session 事件;当创建它的 Session 被归档时,若没有未归档的 Session 引用该目录、创建者的策略不是只读,且 Git 未发现跟踪文件改动、未跟踪文件、子模块仓库、锁或分离 HEAD,就用不带 force 的 `git worktree remove` 删除它。`node_modules` 之类被忽略的文件,以及 `git status` 不报告的 skip-worktree 或 assume-unchanged 文件改动,都不会保留目录,Git 会丢弃它们。分支和提交仍会保留。服务的 `retainOnArchive` 设置默认关闭,开启后改为保留所有检出目录。本版本之前创建的检出目录没有该事件,不会被扫描;更早归档的 Session 也不会。参见[工作树服务](../../../../packages/experimental/worktree/README.zh.md)。 Forge bundle 为面板和 `create_worktree` 工具挂载同一个 `worktree` 服务。隔离第二个服务的 `forge-worktree` 行与 `optional-forge-panel` 组已移除;面板是顶层的 `forge-panel` 行。 ## 迁移 -1. 如果检出目录必须供 DSH 之外继续使用,请保持其 Session 未归档,在归档前用 `git worktree lock ` 锁定它,或在插件页 Forge 套件的 worktree 行上开启**归档时不删除工作树**(`retainOnArchive`),它会保留所有检出目录。被忽略的文件不会保留。 +1. 如果检出目录必须供 DSH 之外继续使用,请保持其 Session 未归档,在归档前用 `git worktree lock ` 锁定它,或在插件页 Forge 套件的 worktree 行上开启**归档时不删除工作树**(`retainOnArchive`),它会保留所有检出目录。被忽略的文件不会保留,skip-worktree 或 assume-unchanged 文件的改动也不会:归档前请提交这些改动,或用 `git update-index --no-skip-worktree` 或 `--no-assume-unchanged` 清除标记。 2. 要继续使用已归档且检出目录已删除的 Session,请取消归档并使用**恢复工作树**。保留的分支会在记录的路径重新检出。 3. 在 profile 的 `cordis.patch.yml` 中,把针对 `forge-worktree` 的覆盖迁到 `worktree` 行,把针对 `optional-forge-panel` 的覆盖迁到 `forge-panel` 行;对 `forge-panel` 设置 `disabled: true` 可以只关闭面板。 4. 用 `git branch --list` 确认分支与提交仍然存在,并确认**恢复工作树**使记录的检出目录重新可用。Session 日志无需迁移。 diff --git a/packages/experimental/worktree/README.i18n.yaml b/packages/experimental/worktree/README.i18n.yaml index c964c25be1..788a8dfda7 100644 --- a/packages/experimental/worktree/README.i18n.yaml +++ b/packages/experimental/worktree/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-worktree/use-this-package: - en: c788af94d52dd001 - zh: 200580d1a97048eb + en: 56af10241dc270ee + zh: 2519395ab9641235 /deepseek-ai-dsh-experimental-worktree/understand-the-implementation: en: 6971a7ca9073bef1 zh: b977c28a75c7f963 diff --git a/packages/experimental/worktree/README.md b/packages/experimental/worktree/README.md index 756ea6803f..4c394a6d2a 100644 --- a/packages/experimental/worktree/README.md +++ b/packages/experimental/worktree/README.md @@ -36,7 +36,7 @@ Mount the service alongside `workingDirectory`, `fs`, `subprocess`, `sandbox`, ` `ctx.worktrees.restore(agent, { path, branch, baseCommit, repositoryRoot }, signal?, onPhase?)` checks a removed worktree out again at its recorded directory. It validates the recorded repository root, full commit object name, branch, and canonical target inside the configured pool before mutation. It removes only the missing target's unlocked registration with `git worktree remove`; other worktrees and locked registrations remain unchanged. It then checks out the branch when it still exists, or creates the branch again at `baseCommit` when it does not, and reports which happened as `branchRecreated`. An existing directory is an error. The calling Session's working directory does not change, and changes that were never committed are not recovered. Checkout disables filters as creation does. `onPhase` receives `prepare` before validation, target-registration cleanup, and branch creation, and `checkout` before the files are written; it must not throw. -Every creation appends a `plugin:worktree/created` record to the Session log after the directory change, so the `worktree` projection lists the Session's checkouts with `owned` false for one inside a fork-inherited prefix. When the Workspace archives a Session, the service registers one `scheduled` cleanup for the first checkout that Session created itself and returns; the inspection runs in the background. While `retainOnArchive` is on, archiving registers nothing and every checkout stays; the field is volatile and read at each archive, and the [Forge panel](../client-ui-forge/README.md) edits it as a switch on the bundle's worktree row of the Plugins page. The checkout is retained while any unarchived Session refers to it, by a recorded creation or by a working directory inside it (a Session beneath an archived subagent ancestor counts as archived; cold Sessions are read from the projection cache or their log), and while the creator's sandbox policy is read-only. An eligible checkout is removed with a non-force `git worktree remove` under the creator's policy after a second use check inside the path lock, which also holds `ensure` and `set` into that path through `working-directory/access`; the branch and its commits remain. Tracked changes, untracked files, hidden index flags, submodules, locks, detached HEAD, a mismatched branch, and targets outside the configured pool retain the checkout; ignored files do not, as Git itself discards them. Processes are not consulted: a process still writing there makes Git refuse, and an idle one fails its next command. Unarchiving cancels an unadmitted removal and waits for an admitted one. `cleanupOf(session, revision)` reads the attempt, or waits for a named revision to settle, as `scheduled`, `retained`, `removed`, or `failed`; a failure leaves the Session archived and logs a warning. Checkouts created before this event existed, and Sessions archived earlier, are not swept. +Every creation appends a `plugin:worktree/created` record to the Session log after the directory change, so the `worktree` projection lists the Session's checkouts with `owned` false for one inside a fork-inherited prefix. When the Workspace archives a Session, the service registers one `scheduled` cleanup for the first checkout that Session created itself and returns; the inspection runs in the background. While `retainOnArchive` is on, archiving registers nothing and every checkout stays; the field is volatile and read at each archive, and the [Forge panel](../client-ui-forge/README.md) edits it as a switch on the bundle's worktree row of the Plugins page. The checkout is retained while any unarchived Session refers to it, by a recorded creation or by a working directory inside it (a Session beneath an archived subagent ancestor counts as archived; cold Sessions are read from the projection cache or their log), and while the creator's sandbox policy is read-only. An eligible checkout is removed with a non-force `git worktree remove` under the creator's policy after a second use check inside the path lock, which also holds `ensure` and `set` into that path through `working-directory/access`; the branch and its commits remain. Tracked changes, untracked files, submodule repositories (Git refuses to remove those), locks, detached HEAD, a mismatched branch, and targets outside the configured pool retain the checkout. Ignored files and edits to skip-worktree or assume-unchanged files do not: `git status` reports neither, and Git discards them with the checkout. Inspection reads `git status` only up to its first entry and streams the worktree listing, so neither is bounded by `maxOutputBytes`. Processes are not consulted: a process still writing there makes Git refuse, and an idle one fails its next command. Unarchiving cancels an unadmitted removal and waits for an admitted one. `cleanupOf(session, revision)` reads the attempt, or waits for a named revision to settle, as `scheduled`, `retained`, `removed`, or `failed`; a failure leaves the Session archived and logs a warning. Checkouts created before this event existed, and Sessions archived earlier, are not swept. The default checkout directory is `/.agents/worktrees/`. The returned `repositoryRoot` is the top-level directory of the caller’s current checkout; when it is a linked checkout, the pool is nested inside it. Omitted names join the `dsh-` prefix, the first eight hexadecimal digits of the UUID in the Session id, a hyphen, and four random hexadecimal digits, such as `dsh-6b1f02c4-9a3e`; a Session id without a UUID gets eight random hexadecimal digits after the prefix. `generatedWorktreeName` exports the same rule for callers that name a worktree before creating it. The service writes a `.gitignore` containing `*` and a trailing newline only when its allocation creates the pool; existing files and ignore rules are preserved. Each Git and Node command runs under its own `timeoutMs` deadline, 5 minutes by default, so checking out a large repository on a slow filesystem can finish; cancellation still stops the running command. Relative `directory`, generated `namePrefix`, executable choices, process limits, and `retainOnArchive` are configurable in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-worktree). diff --git a/packages/experimental/worktree/README.zh.md b/packages/experimental/worktree/README.zh.md index fa3624a49c..262ef39480 100644 --- a/packages/experimental/worktree/README.zh.md +++ b/packages/experimental/worktree/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" `ctx.worktrees.restore(agent, { path, branch, baseCommit, repositoryRoot }, signal?, onPhase?)` 在记录的目录重新检出被删除的 worktree。它在修改前校验记录中的仓库根目录、完整提交对象名、分支和配置池内的规范目标路径。它只通过 `git worktree remove` 移除缺失目标的未锁定登记;其他 worktree 与锁定登记保持不变。随后在分支仍存在时检出该分支,否则在 `baseCommit` 重新创建同名分支,并通过 `branchRecreated` 报告是哪种情况。目录已存在时会报错。调用方 Session 的工作目录不变,从未提交的修改无法恢复。检出与创建时一样禁用过滤器。`onPhase` 在检查目录、清理登记与创建分支之前收到 `prepare`,在写入文件之前收到 `checkout`;它不能抛出异常。 -每次创建都会在目录切换之后向 Session 日志追加一条 `plugin:worktree/created` 事件,因此 `worktree` 投影列出该 Session 的检出目录,位于 fork 继承前缀内的条目 `owned` 为 false。Workspace 归档某个 Session 时,服务为该 Session 自己创建的第一个检出目录登记一条 `scheduled` 清理并立即返回;检查在后台进行。`retainOnArchive` 开启时,归档不登记任何清理,所有检出目录都保留;该字段是 volatile 字段,每次归档时读取,[Forge 面板](../client-ui-forge/README.zh.md)在插件页该套件的 worktree 行上以开关编辑它。只要任一未归档 Session 仍引用该目录——通过记录的创建,或通过位于其中的工作目录(祖先子代理已归档的 Session 视为已归档;冷 Session 从投影缓存或其日志读取)——或创建者的沙箱策略为只读,目录就保留。符合条件的目录在路径锁内再做一次使用检查后,用创建者的策略执行不带 force 的 `git worktree remove`;路径锁同时通过 `working-directory/access` 阻塞进入该路径的 `ensure` 与 `set`;分支及其提交保留。跟踪文件改动、未跟踪文件、隐藏的索引标记、子模块、锁、分离 HEAD、分支不匹配以及位于配置池之外的目标都会保留目录;被忽略的文件不会,因为 Git 自身会丢弃它们。不检查进程:仍在写入的进程会让 Git 拒绝删除,空闲进程的下一条命令会失败。取消归档会取消尚未获准的删除,并等待已获准的删除结束。`cleanupOf(session, revision)` 读取本次尝试,或等待指定修订号结束,状态为 `scheduled`、`retained`、`removed` 或 `failed`;失败时 Session 仍保持归档并记录警告。此事件存在之前创建的检出目录,以及更早归档的 Session,不会被扫描。 +每次创建都会在目录切换之后向 Session 日志追加一条 `plugin:worktree/created` 事件,因此 `worktree` 投影列出该 Session 的检出目录,位于 fork 继承前缀内的条目 `owned` 为 false。Workspace 归档某个 Session 时,服务为该 Session 自己创建的第一个检出目录登记一条 `scheduled` 清理并立即返回;检查在后台进行。`retainOnArchive` 开启时,归档不登记任何清理,所有检出目录都保留;该字段是 volatile 字段,每次归档时读取,[Forge 面板](../client-ui-forge/README.zh.md)在插件页该套件的 worktree 行上以开关编辑它。只要任一未归档 Session 仍引用该目录——通过记录的创建,或通过位于其中的工作目录(祖先子代理已归档的 Session 视为已归档;冷 Session 从投影缓存或其日志读取)——或创建者的沙箱策略为只读,目录就保留。符合条件的目录在路径锁内再做一次使用检查后,用创建者的策略执行不带 force 的 `git worktree remove`;路径锁同时通过 `working-directory/access` 阻塞进入该路径的 `ensure` 与 `set`;分支及其提交保留。跟踪文件改动、未跟踪文件、子模块仓库(Git 拒绝删除这类目录)、锁、分离 HEAD、分支不匹配以及位于配置池之外的目标都会保留目录。被忽略的文件以及对 skip-worktree 或 assume-unchanged 文件的改动不会:`git status` 不报告这两类内容,Git 删除目录时一并丢弃它们。检查只读取 `git status` 的第一条记录,并以流式读取工作树列表,因此两者都不受 `maxOutputBytes` 限制。不检查进程:仍在写入的进程会让 Git 拒绝删除,空闲进程的下一条命令会失败。取消归档会取消尚未获准的删除,并等待已获准的删除结束。`cleanupOf(session, revision)` 读取本次尝试,或等待指定修订号结束,状态为 `scheduled`、`retained`、`removed` 或 `failed`;失败时 Session 仍保持归档并记录警告。此事件存在之前创建的检出目录,以及更早归档的 Session,不会被扫描。 默认检出目录是 `/.agents/worktrees/`。返回的 `repositoryRoot` 是调用方当前检出目录的顶层目录;如果它是链接检出目录,工作树父目录就嵌套在其中。省略名称时,名称由前缀 `dsh-`、Session id 中 UUID 的前 8 位十六进制数、一个连字符和 4 位随机十六进制数组成,例如 `dsh-6b1f02c4-9a3e`;Session id 不含 UUID 时,前缀后接 8 位随机十六进制数。`generatedWorktreeName` 导出同一规则,供在创建前确定名称的调用方使用。仅当服务的分配操作创建了父目录时,服务才会写入内容为 `*` 加换行符的 `.gitignore`;已有文件与忽略规则保持不变。每条 Git 和 Node 命令各自受 `timeoutMs` 时限约束,默认 5 分钟,使大仓库在较慢的文件系统上也能完成检出;取消仍会终止正在运行的命令。相对路径 `directory`、生成名称使用的 `namePrefix`、可执行程序、进程限制与 `retainOnArchive` 可在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-worktree)中查阅。 diff --git a/packages/experimental/worktree/src/index.ts b/packages/experimental/worktree/src/index.ts index 3c74e3f73c..683798a5d4 100644 --- a/packages/experimental/worktree/src/index.ts +++ b/packages/experimental/worktree/src/index.ts @@ -15,11 +15,12 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { WorktreeReclamation } from './archive.ts' import { PREPARE_DIRECTORY } from './directory.ts' import { generatedWorktreeName } from './name.ts' -import { runCommand } from './process.ts' +import { runCommand, scanCommand } from './process.ts' import type { ProcessConfig } from './process.ts' import { CREATED_RECORD, worktreeProjection } from './projection.ts' import type { WorktreeProjectionState } from './projection.ts' import { checkoutIdentity, registration, removeCheckout } from './removal.ts' +import type { GitScan } from './removal.ts' import type { CreatedWorktree, CreateWorktreePhase, CreateWorktreeRequest, RestoredWorktree, RestoreWorktreeRequest, WorktreeCleanup, } from './types.ts' @@ -47,7 +48,10 @@ export interface Config { timeoutMs?: number /** Subprocess termination grace in milliseconds. */ graceMs?: number - /** Maximum captured bytes per subprocess output stream. */ + /** + * Maximum captured bytes per subprocess output stream; archive inspection + * streams `git status` and the worktree listing, so it bounds only their stderr. + */ maxOutputBytes?: number } @@ -220,8 +224,9 @@ export class WorktreeService extends Service { ): Promise { const control = AbortSignal.any([this.lifetime.signal, signal]) control.throwIfAborted() - const run = this.gitRunner(this.ctx.sandboxPolicy.resolve({ session }), control) - const operation = removeCheckout(this.ctx, this.config.directory, request, run, control, admit) + const policy = this.ctx.sandboxPolicy.resolve({ session }) + const git = { read: this.gitRunner(policy, control), scan: this.gitScanner(policy, control) } + const operation = removeCheckout(this.ctx, this.config.directory, request, git, control, admit) this.pending.add(operation) try { return await operation } finally { this.pending.delete(operation) } } @@ -296,7 +301,8 @@ export class WorktreeService extends Service { if (identity === null) throw new Error('worktree restoration must use a canonical directory inside the configured pool of the source checkout root') await run(repositoryRoot, ['check-ref-format', `refs/heads/${branch}`]) await run(repositoryRoot, ['rev-parse', '--verify', '--end-of-options', `${baseCommit}^{commit}`]) - const fields = await registration(this.ctx, { path, branch, baseCommit, repositoryRoot }, identity.target, run, signal) + const scan = this.gitScanner(policy, signal) + const fields = await registration(this.ctx, { path, branch, baseCommit, repositoryRoot }, identity.target, scan, signal) if (fields !== undefined) { if (fields.some(field => field === 'locked' || field.startsWith('locked '))) throw new Error(`worktree is locked: ${path}`) if (await this.ctx.fs.lstat(path, undefined, signal) !== undefined) throw new Error(`worktree path already exists: ${path}`) @@ -320,15 +326,23 @@ export class WorktreeService extends Service { } } - /** Confine Git commands to the caller's sandbox policy, with hooks and the filesystem monitor disabled. */ + /** Confine Git commands to the caller's sandbox policy, collecting bounded stdout. */ private gitRunner(policy: SandboxExecutionPolicy, signal: AbortSignal): GitRun { return (directory, args, allowedExitCodes, env) => runCommand( - this.ctx, this.config, policy, directory, - [this.config.gitCommand, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args], - signal, allowedExitCodes, env, + this.ctx, this.config, policy, directory, this.gitArgv(args), signal, allowedExitCodes, env, ) } + /** Confine Git commands to the caller's sandbox policy, streaming stdout instead of collecting it. */ + private gitScanner(policy: SandboxExecutionPolicy, signal: AbortSignal): GitScan { + return (directory, args, onField) => scanCommand(this.ctx, this.config, policy, directory, this.gitArgv(args), signal, onField) + } + + /** The configured Git executable with hooks and the filesystem monitor disabled, followed by `args`. */ + private gitArgv(args: string[]): [string, ...string[]] { + return [this.config.gitCommand, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args] + } + /** Allocate the checkout directory exclusively inside the pool, creating a self-ignoring pool when it is new. */ private async prepareDirectory( policy: SandboxExecutionPolicy, diff --git a/packages/experimental/worktree/src/process.ts b/packages/experimental/worktree/src/process.ts index 74c8fa1c1b..94846759c1 100644 --- a/packages/experimental/worktree/src/process.ts +++ b/packages/experimental/worktree/src/process.ts @@ -1,8 +1,11 @@ /** Bounded, cancellable commands under the calling Session's existing file policy. */ +import type { Readable } from 'node:stream' import type { Context } from '@deepseek-ai/cordis' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' -import type { SubprocessCollectedOutputs } from '@deepseek-ai/dsh-subprocess' +import type { + SubprocessCollect, SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputReader, +} from '@deepseek-ai/dsh-subprocess' import { deadline } from '@deepseek-ai/dsh-timeout' /** Resolved process resource limits owned by the worktree service. */ @@ -46,39 +49,109 @@ export async function runCommand( env: NodeJS.ProcessEnv = {}, ): Promise<{ stdout: string; exitCode: number }> { using timeout = deadline(signal, config.timeoutMs, 'WORKTREE_TIMEOUT') - timeout.signal.throwIfAborted() - const program = await ctx.subprocess.resolveExecutable(argv[0], undefined, timeout.signal) - const command = [program, ...argv.slice(1)] - const wrapped = policy.mode === 'danger-full-access' - ? command - : (await ctx.sandbox.confine(command, { ...policy, mode: policy.mode }, timeout.signal)).argv - timeout.signal.throwIfAborted() - const child = ctx.subprocess.spawn({ - argv: wrapped, - cwd, - env: { ...GIT_ENV, GIT_TERMINAL_PROMPT: '0', GIT_LFS_SKIP_SMUDGE: '1', GIT_NO_LAZY_FETCH: '1', ...env }, - signal: timeout.signal, - graceMs: config.graceMs, - stdio: { - stdin: 'ignore', - stdout: { maxBytes: config.maxOutputBytes }, - stderr: { maxBytes: config.maxOutputBytes }, - }, - }) + const child = await spawnCommand(ctx, config, policy, cwd, argv, timeout.signal, env, { maxBytes: config.maxOutputBytes }) try { const outcome = await child.done timeout.signal.throwIfAborted() // The spawn spec above collects both streams. const captured = child.collected as Required + const exitCode = allowedExitCode(outcome, captured.stderr, allowedExitCodes) const stdout = captured.stdout.readFrom(0) - const stderr = captured.stderr.readFrom(0) - if (outcome.exitCode === null || !allowedExitCodes.includes(outcome.exitCode)) { - throw new Error(`worktree command failed (${outcome.signal ?? outcome.exitCode}): ${stderr.text.trim()}`) - } if (stdout.lossy) throw new Error('worktree command output exceeds maxOutputBytes') - return { stdout: stdout.text, exitCode: outcome.exitCode } + return { stdout: stdout.text, exitCode } } finally { child.terminate() await child.waitForExit() } } + +/** + * Run an argv as {@link runCommand} does, but pass each NUL-terminated stdout + * field to `onField` as it arrives instead of retaining the output, so the size + * of stdout is unbounded and `maxOutputBytes` limits only stderr. An + * unterminated remainder is ignored. A false return from `onField` terminates + * the command, whose exit status is then not checked; otherwise only status 0 succeeds. + * @param ctx - subprocess and sandbox providers in the same execution world. + * @param config - complete process resource limits. + * @param policy - unchanged calling Session policy. + * @param cwd - canonical process working directory. + * @param argv - executable followed by literal arguments. + * @param signal - operation cancellation. + * @param onField - synchronous consumer of each field in order; false ends the command. + * @returns true when `onField` ended the command, false when it exited successfully first. + */ +export async function scanCommand( + ctx: Context, + config: ProcessConfig, + policy: SandboxExecutionPolicy, + cwd: string, + argv: readonly [string, ...string[]], + signal: AbortSignal, + onField: (field: string) => boolean, +): Promise { + using timeout = deadline(signal, config.timeoutMs, 'WORKTREE_TIMEOUT') + const child = await spawnCommand(ctx, config, policy, cwd, argv, timeout.signal, {}, 'pipe') + try { + // The spawn spec above pipes stdout and collects stderr. + const stdout = child.stdout as Readable + stdout.setEncoding('utf8') + let pending = '' + let stopped = false + for await (const chunk of stdout as AsyncIterable) { + const fields = (pending + chunk).split('\0') + pending = fields.splice(-1).join('') + if (!fields.every(field => onField(field))) { + stopped = true + break + } + } + if (stopped) child.terminate() + const outcome = await child.done + timeout.signal.throwIfAborted() + if (!stopped) allowedExitCode(outcome, child.collected.stderr as SubprocessOutputReader, [0]) + return stopped + } finally { + child.terminate() + await Promise.allSettled([child.done]) + await child.waitForExit() + } +} + +/** Resolve, confine, and start one command whose stderr is collected within `maxOutputBytes`. */ +async function spawnCommand( + ctx: Context, + config: ProcessConfig, + policy: SandboxExecutionPolicy, + cwd: string, + argv: readonly [string, ...string[]], + signal: AbortSignal, + env: NodeJS.ProcessEnv, + stdout: 'pipe' | SubprocessCollect, +): Promise { + signal.throwIfAborted() + const program = await ctx.subprocess.resolveExecutable(argv[0], undefined, signal) + const command = [program, ...argv.slice(1)] + const wrapped = policy.mode === 'danger-full-access' + ? command + : (await ctx.sandbox.confine(command, { ...policy, mode: policy.mode }, signal)).argv + signal.throwIfAborted() + return ctx.subprocess.spawn({ + argv: wrapped, + cwd, + env: { ...GIT_ENV, GIT_TERMINAL_PROMPT: '0', GIT_LFS_SKIP_SMUDGE: '1', GIT_NO_LAZY_FETCH: '1', ...env }, + signal, + graceMs: config.graceMs, + stdio: { stdin: 'ignore', stdout, stderr: { maxBytes: config.maxOutputBytes } }, + }) +} + +/** + * The exit code of a command that exited with an allowed status. + * @throws when the command was signalled or exited with another status, quoting its stderr. + */ +function allowedExitCode(outcome: SubprocessOutcome, stderr: SubprocessOutputReader, allowedExitCodes: readonly number[]): number { + if (outcome.exitCode === null || !allowedExitCodes.includes(outcome.exitCode)) { + throw new Error(`worktree command failed (${outcome.signal ?? outcome.exitCode}): ${stderr.readFrom(0).text.trim()}`) + } + return outcome.exitCode +} diff --git a/packages/experimental/worktree/src/removal.ts b/packages/experimental/worktree/src/removal.ts index f9e4082bf7..bcec543e6c 100644 --- a/packages/experimental/worktree/src/removal.ts +++ b/packages/experimental/worktree/src/removal.ts @@ -3,9 +3,24 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-fs' import type { RestoreWorktreeRequest } from './types.ts' -/** Runs one Git command in the owner's confinement and reports its output. */ +/** Runs one Git command in the owner's confinement and reports its complete bounded output. */ export type GitRead = (directory: string, args: string[]) => Promise<{ stdout: string; exitCode: number }> +/** + * Runs one Git command in the owner's confinement and passes each + * NUL-terminated stdout field to `onField` without retaining the output; + * false from `onField` ends the command. Resolves true when `onField` ended it. + */ +export type GitScan = (directory: string, args: string[], onField: (field: string) => boolean) => Promise + +/** The confined Git runners inspection and removal use. */ +export interface GitCommands { + /** Commands whose output stays small, read whole. */ + readonly read: GitRead + /** Commands whose output grows with the repository or its worktrees, read as it arrives. */ + readonly scan: GitScan +} + /** Resolved canonical locations of a recorded checkout that validation confirmed. */ export interface CheckoutIdentity { readonly root: Awaited> @@ -40,22 +55,30 @@ export async function checkoutIdentity( /** * Find the registration of `target` among the source repository's worktrees. + * The listing grows with the number of worktrees and is scanned as it arrives. * @param ctx - execution-world filesystem provider. * @param request - saved checkout identity. * @param target - resolved checkout directory. - * @param run - bounded Git runner with the owner's policy. + * @param scan - streaming Git runner with the owner's policy. * @param signal - cancellation of the lookups. * @returns the porcelain fields of the matching entry, or undefined when none is registered. */ export async function registration( - ctx: Context, request: RestoreWorktreeRequest, target: CheckoutIdentity['target'], run: GitRead, signal: AbortSignal, + ctx: Context, request: RestoreWorktreeRequest, target: CheckoutIdentity['target'], scan: GitScan, signal: AbortSignal, ): Promise { - const registered = await run(request.repositoryRoot, ['worktree', 'list', '--porcelain', '-z']) - for (const entry of registered.stdout.split('\0\0').map(entry => entry.split('\0'))) { - const registeredPath = entry.find(field => field.startsWith('worktree '))?.slice('worktree '.length) - if (registeredPath === undefined) continue - const recorded = await ctx.fs.resolve(registeredPath, { cwd: request.repositoryRoot, signal }) - if (recorded.targetKey === target.targetKey) return entry + const entries: { path: string; fields: string[] }[] = [] + // Each entry of the -z porcelain listing starts with its worktree line and ends with an empty field. + let fields: string[] = [] + await scan(request.repositoryRoot, ['worktree', 'list', '--porcelain', '-z'], (field) => { + if (field.startsWith('worktree ')) { + fields = [field] + entries.push({ path: field.slice('worktree '.length), fields }) + } else if (field.length > 0) fields.push(field) + return true + }) + for (const entry of entries) { + const recorded = await ctx.fs.resolve(entry.path, { cwd: request.repositoryRoot, signal }) + if (recorded.targetKey === target.targetKey) return entry.fields } return undefined } @@ -67,51 +90,51 @@ export async function registration( * @param ctx - execution-world filesystem provider. * @param directory - configured relative worktree pool. * @param request - saved checkout identity. - * @param run - bounded Git runner with the owner's policy. + * @param git - confined Git runners with the owner's policy. * @param signal - cancellation before or during removal. * @param admit - synchronous final admission after inspection; false retains the checkout. * @returns true when removed; false when inspection or admission retains it; Git failures reject. */ export async function removeCheckout( - ctx: Context, directory: string, request: RestoreWorktreeRequest, run: GitRead, signal: AbortSignal, admit: () => boolean, + ctx: Context, directory: string, request: RestoreWorktreeRequest, git: GitCommands, signal: AbortSignal, admit: () => boolean, ): Promise { - if (!await inspectRemoval(ctx, directory, request, run, signal)) return false + if (!await inspectRemoval(ctx, directory, request, git, signal)) return false signal.throwIfAborted() if (!admit()) return false - await run(request.repositoryRoot, ['worktree', 'remove', '--', request.path]) + await git.read(request.repositoryRoot, ['worktree', 'remove', '--', request.path]) return true } /** - * Verify checkout identity and account for every file that Git's non-force - * removal would refuse: tracked changes, untracked files, hidden index flags, - * and submodules. Ignored files are not counted; `git worktree remove` discards them. + * Verify checkout identity and that `git status` reports no tracked changes or + * untracked files; its output is read only up to the first entry. Ignored files + * are not counted, nor are edits to skip-worktree or assume-unchanged files, + * which `git status` hides; `git worktree remove` discards both. A checkout + * whose administrative directory holds submodule repositories is ineligible, + * as Git refuses to remove it. * @param ctx - execution-world filesystem provider. * @param directory - configured relative worktree pool. * @param request - saved checkout identity. - * @param run - bounded Git runner with the owner's policy. + * @param git - confined Git runners with the owner's policy. * @param signal - cancellation of inspection. * @returns false for an ineligible checkout; inspection failures reject. */ export async function inspectRemoval( - ctx: Context, directory: string, request: RestoreWorktreeRequest, run: GitRead, signal: AbortSignal, + ctx: Context, directory: string, request: RestoreWorktreeRequest, git: GitCommands, signal: AbortSignal, ): Promise { const { path, branch } = request - const identity = await checkoutIdentity(ctx, directory, request, run, signal) + const identity = await checkoutIdentity(ctx, directory, request, git.read, signal) if (identity === null) return false if ((await ctx.fs.lstat(path, undefined, signal))?.type !== 'directory') return false - const fields = await registration(ctx, request, identity.target, run, signal) + const fields = await registration(ctx, request, identity.target, git.scan, signal) // The primary registration is excluded even when the configured pool contains it. if (fields === undefined || !fields.includes(`branch refs/heads/${branch}`) || fields.some(field => field === 'detached' || field.startsWith('locked') || field.startsWith('prunable'))) return false - const head = await run(path, ['symbolic-ref', 'HEAD']) + const head = await git.read(path, ['symbolic-ref', 'HEAD']) if (head.stdout.trim() !== `refs/heads/${branch}`) return false - const status = await run(path, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none']) - if (status.stdout.length !== 0) return false - // Git status deliberately hides skip-worktree and assume-unchanged files. - const flags = await run(path, ['ls-files', '-v', '-z']) - if (flags.stdout.split('\0').some(entry => entry.length > 0 && (entry[0] === 'S' || /^[a-z]/.test(entry)))) return false - const index = await run(path, ['ls-files', '--stage', '-z']) - if (index.stdout.split('\0').some(entry => entry.startsWith('160000 '))) return false - return true + const changed = await git.scan(path, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'], () => false) + if (changed) return false + // Git refuses removal whenever this directory exists, populated or not. + const modules = await git.read(path, ['rev-parse', '--git-path', 'modules']) + return await ctx.fs.lstat(modules.stdout.replace(/\r?\n$/, ''), { cwd: path }, signal) === undefined } diff --git a/packages/experimental/worktree/tests/archive.spec.ts b/packages/experimental/worktree/tests/archive.spec.ts index e2b5c8d47a..d8d18c91ed 100644 --- a/packages/experimental/worktree/tests/archive.spec.ts +++ b/packages/experimental/worktree/tests/archive.spec.ts @@ -63,7 +63,7 @@ function agentFor(ctx: Context, session: Session): Agent { return agent } -async function fixture(options: { cache?: boolean; retainOnArchive?: boolean } = {}) { +async function fixture(options: { cache?: boolean; retainOnArchive?: boolean; maxOutputBytes?: number } = {}) { const temporary = await realpath(await mkdtemp(join(tmpdir(), 'dsh-worktree-archive-'))) cleanups.push(() => rm(temporary, { recursive: true, force: true })) const root = await repository(temporary) @@ -87,7 +87,8 @@ async function fixture(options: { cache?: boolean; retainOnArchive?: boolean } = ctx.provide('storageDomain', domain) if (options.cache === true) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 1_000, writeIntervalMs: 60_000 }) await ctx.plugin(WorkspaceRegistry) - const worktrees = ctx.plugin(WorktreeService, options.retainOnArchive === undefined ? {} : { retainOnArchive: options.retainOnArchive }) + const { cache: _cache, ...config } = options + const worktrees = ctx.plugin(WorktreeService, config) await worktrees const session = ctx.sessions.create(SessionId('archive-owner'), { meta: { cwd: root } }) const agent = agentFor(ctx, session) @@ -137,6 +138,39 @@ describe('archive-triggered worktree cleanup', () => { expect(f.ctx.workspaceRegistry.archivedSessionIds).toContain(f.session.id) }) + it('removes a clean checkout whose file listing exceeds maxOutputBytes', async () => { + const f = await fixture({ maxOutputBytes: 1_024 }) + for (let index = 0; index < 64; index++) { + await writeFile(join(f.worktree.path, `file-${String(index).padStart(3, '0')}-${'x'.repeat(24)}.txt`), `${index}\n`) + } + await git(f.worktree.path, 'add', '.') + await git(f.worktree.path, 'commit', '-m', 'many files') + expect((await git(f.worktree.path, 'ls-files', '-z')).length).toBeGreaterThan(1_024) + await f.ctx.workspaceRegistry.archiveSession(f.session.id) + expect(await settled(f)).toMatchObject({ status: 'removed' }) + await expect(access(f.worktree.path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('retains a checkout whose status output exceeds maxOutputBytes instead of failing', async () => { + const f = await fixture({ maxOutputBytes: 1_024 }) + for (let index = 0; index < 64; index++) { + await writeFile(join(f.worktree.path, `untracked-${String(index).padStart(3, '0')}-${'x'.repeat(24)}.txt`), `${index}\n`) + } + expect((await git(f.worktree.path, 'status', '--porcelain', '-z', '--untracked-files=all')).length).toBeGreaterThan(1_024) + await f.ctx.workspaceRegistry.archiveSession(f.session.id) + expect(await settled(f)).toMatchObject({ status: 'retained' }) + await access(join(f.worktree.path, 'untracked-000-xxxxxxxxxxxxxxxxxxxxxxxx.txt')) + }) + + it('removes a clean checkout when the worktree listing exceeds maxOutputBytes', async () => { + const f = await fixture({ maxOutputBytes: 1_024 }) + for (let index = 0; index < 8; index++) await git(f.root, 'worktree', 'add', '--detach', join(f.temporary, `extra-${index}`)) + expect((await git(f.root, 'worktree', 'list', '--porcelain', '-z')).length).toBeGreaterThan(1_024) + await f.ctx.workspaceRegistry.archiveSession(f.session.id) + expect(await settled(f)).toMatchObject({ status: 'removed' }) + await expect(access(f.worktree.path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('returns from admission before the inspection runs', async () => { const f = await fixture() const release = Promise.withResolvers() diff --git a/packages/experimental/worktree/tests/removal.spec.ts b/packages/experimental/worktree/tests/removal.spec.ts index d4afb66f85..27be81eca6 100644 --- a/packages/experimental/worktree/tests/removal.spec.ts +++ b/packages/experimental/worktree/tests/removal.spec.ts @@ -4,16 +4,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { inspectRemoval, removeCheckout } from '../src/removal.ts' -import type { GitRead } from '../src/removal.ts' +import type { GitCommands, GitRead, GitScan } from '../src/removal.ts' import type { RestoreWorktreeRequest } from '../src/types.ts' import { git, harness, repository, testAgent } from './harness.ts' /** A Git runner over the fixture's unconfined git, as the service's danger-full-access runner behaves. */ const run: GitRead = async (directory, args) => ({ stdout: await git(directory, ...args), exitCode: 0 }) +/** Pass each NUL-terminated field of `text` to `onField` as the streaming runner does; true when it stopped early. */ +function scanText(text: string, onField: (field: string) => boolean): boolean { + return !text.split('\0').slice(0, -1).every(field => onField(field)) +} + +/** A streaming runner over the same git; it reads the output whole, then passes its fields in order. */ +const scan: GitScan = async (directory, args, onField) => scanText(await git(directory, ...args), onField) + +const commands: GitCommands = { read: run, scan } + /** The inspection-and-removal step archive cleanup runs, with the pool the harness configures. */ function remove(ctx: Context, _agent: Agent, request: RestoreWorktreeRequest, admit: () => boolean = () => true): Promise { - return removeCheckout(ctx, '.agents/worktrees', request, run, new AbortController().signal, admit) + return removeCheckout(ctx, '.agents/worktrees', request, commands, new AbortController().signal, admit) } const disposers: (() => Promise)[] = [] @@ -48,21 +58,42 @@ describe('safe worktree removal', () => { expect(await git(worktree.path, 'rev-parse', 'HEAD')).toBe(head) }) - it.each(['modified', 'staged', 'untracked', 'assume-unchanged', 'skip-worktree'] as const)( + it.each(['modified', 'staged', 'untracked'] as const)( 'retains %s files', async (kind) => { const { ctx, agent, worktree } = await fixture() - const tracked = join(worktree.path, 'tracked.txt') if (kind === 'untracked') await writeFile(join(worktree.path, 'new.txt'), 'keep') else { - await writeFile(tracked, 'keep\n') + await writeFile(join(worktree.path, 'tracked.txt'), 'keep\n') if (kind === 'staged') await git(worktree.path, 'add', 'tracked.txt') - if (kind === 'assume-unchanged' || kind === 'skip-worktree') await git(worktree.path, 'update-index', `--${kind}`, 'tracked.txt') } expect(await remove(ctx, agent, worktree)).toBe(false) await access(worktree.path) }, ) + it.each(['assume-unchanged', 'skip-worktree'] as const)( + 'removes a checkout whose only edits are to %s files, which git status hides', async (flag) => { + const { ctx, agent, worktree } = await fixture() + await writeFile(join(worktree.path, 'tracked.txt'), 'hidden edit\n') + await git(worktree.path, 'update-index', `--${flag}`, 'tracked.txt') + expect(await git(worktree.path, 'status', '--porcelain')).toBe('') + expect(await remove(ctx, agent, worktree)).toBe(true) + await expect(access(worktree.path)).rejects.toMatchObject({ code: 'ENOENT' }) + }, + ) + + it('retains a checkout holding a submodule repository, which Git refuses to remove', async () => { + const { ctx, agent, worktree, root } = await fixture() + const source = await repository() + disposers.push(() => rm(source, { recursive: true, force: true })) + await git(worktree.path, '-c', 'protocol.file.allow=always', 'submodule', 'add', source, 'nested') + await git(worktree.path, 'commit', '-m', 'submodule') + expect(await git(worktree.path, 'status', '--porcelain')).toBe('') + expect(await remove(ctx, agent, worktree)).toBe(false) + await access(join(worktree.path, 'nested', 'tracked.txt')) + await expect(git(root, 'worktree', 'remove', worktree.path)).rejects.toThrow('working trees containing submodules cannot be moved or removed') + }) + it('removes a checkout that holds only ignored files, as Git does without force', async () => { const { ctx, agent, worktree } = await fixture() await git(worktree.path, 'config', 'core.excludesFile', join(worktree.repositoryRoot, 'exclude')) @@ -94,17 +125,18 @@ describe('safe worktree removal', () => { }, ) - it.each(['repository identity', 'registration', 'checked-out branch', 'submodule'] as const)( + it.each(['repository identity', 'registration', 'checked-out branch'] as const)( 'retains a checkout when the %s changes during inspection', async (changed) => { const { ctx, worktree, root } = await fixture() - const run = vi.fn(async (directory: string, args: string[]) => { + const read = vi.fn(async (directory: string, args: string[]) => { if (changed === 'repository identity' && args[0] === 'rev-parse') return { stdout: worktree.path, exitCode: 0 } - if (changed === 'registration' && args[0] === 'worktree') return { stdout: `worktree ${root}\0branch refs/heads/main\0\0\0`, exitCode: 0 } if (changed === 'checked-out branch' && args[0] === 'symbolic-ref') return { stdout: 'refs/heads/other', exitCode: 0 } - if (changed === 'submodule' && args.includes('--stage')) return { stdout: `160000 ${worktree.baseCommit} 0\tsubmodule\0`, exitCode: 0 } return { stdout: await git(directory, ...args), exitCode: 0 } }) - expect(await inspectRemoval(ctx, '.agents/worktrees', worktree, run, new AbortController().signal)).toBe(false) + const listed: GitScan = async (directory, args, onField) => changed === 'registration' && args[0] === 'worktree' + ? scanText(`worktree ${root}\0branch refs/heads/main\0\0`, onField) + : await scan(directory, args, onField) + expect(await inspectRemoval(ctx, '.agents/worktrees', worktree, { read, scan: listed }, new AbortController().signal)).toBe(false) await access(worktree.path) }, ) diff --git a/packages/experimental/worktree/tests/worktree.spec.ts b/packages/experimental/worktree/tests/worktree.spec.ts index c1beb17c4a..33c9b84f21 100644 --- a/packages/experimental/worktree/tests/worktree.spec.ts +++ b/packages/experimental/worktree/tests/worktree.spec.ts @@ -414,6 +414,22 @@ describe('restoring removed worktrees', () => { expect(ctx.workingDirectory.get(agent.session)).toBe(root) }) + it('finds the stale registration when the worktree listing exceeds maxOutputBytes', async () => { + const root = await repo() + ctx = await harness(root, { maxOutputBytes: 1_024 }) + const agent = testAgent(ctx, root) + const created = await ctx.worktrees.create(agent, { name: 'topic' }) + await ctx.workingDirectory.set(agent, root) + for (let index = 0; index < 8; index++) await git(root, 'worktree', 'add', '--detach', join(root, '.agents/worktrees', `extra-${index}`)) + expect((await git(root, 'worktree', 'list', '--porcelain', '-z')).length).toBeGreaterThan(1_024) + await rm(created.path, { recursive: true, force: true }) + + expect(await ctx.worktrees.restore(agent, created)).toEqual({ path: created.path, branchRecreated: false }) + + expect(await git(created.path, 'symbolic-ref', '--short', 'HEAD')).toBe('topic') + expect(await registered(root)).toContain(gitPath(created.path)) + }) + it('creates a deleted branch again at its base commit, without the commits it had gained', async () => { const root = await repo() ctx = await harness(root) From b02529e520756f2fe7f0a81677d210568b12c9c8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 9 Nov 2026 11:21:00 +0800 Subject: [PATCH 65/81] feat(forge): write review threads and the pending review through the provider ForgeProvider gains an optional writeReview for replies, resolution, comment edits and deletions, and the viewer's pending review, which review reads now report with the viewer's permissions. The GitHub provider checks the head and the pending review in one query, then sends one GraphQL mutation on stdin and never retries it; a reply at once is refused while a pending review exists, since GitHub adds it to that review. The forge panel Remote changeRequestReviewWrite drops the kept threads after every write, because resolving a thread does not move a pull request's updatedAt. --- .../experimental/client-ui-forge/src/index.ts | 92 ++++++- .../client-ui-forge/src/review-cache.ts | 43 ++- .../tests/review-cache.host.spec.ts | 37 ++- .../client-ui-forge/tests/review.host.spec.ts | 107 +++++++- .../experimental/forge-github/package.json | 1 + .../experimental/forge-github/src/provider.ts | 15 +- .../forge-github/src/review-writes.ts | 209 +++++++++++++++ .../experimental/forge-github/src/threads.ts | 61 ++++- .../forge-github/tests/review-writes.spec.ts | 253 ++++++++++++++++++ .../forge-github/tests/threads.spec.ts | 63 ++++- .../experimental/forge-github/tsconfig.json | 3 + packages/experimental/forge/src/index.ts | 31 ++- packages/experimental/forge/src/types.ts | 68 ++++- .../experimental/forge/tests/registry.spec.ts | 21 +- .../experimental/forge/tests/watch.spec.ts | 32 ++- pnpm-lock.yaml | 3 + 16 files changed, 987 insertions(+), 52 deletions(-) create mode 100644 packages/experimental/forge-github/src/review-writes.ts create mode 100644 packages/experimental/forge-github/tests/review-writes.spec.ts diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index 6a1baaa71d..aa6e09c9d8 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -6,8 +6,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-api-remote-access' import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { - ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeReviewData, ForgeReviewRequest, - ForgeReviewResult, MergeMethod, + ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeLineComment, ForgeReviewData, + ForgeReviewRequest, ForgeReviewResult, ForgeReviewWrite, ForgeReviewWriteResult, MergeMethod, } from '@deepseek-ai/dsh-experimental-forge' import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session' import type { WorktreeCleanup } from '@deepseek-ai/dsh-experimental-worktree/types' @@ -466,8 +466,8 @@ export class ForgePanelController extends TypertRemoteService { /** * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read * at the same head commit and update time are reused from memory, for any Session, until they are - * `reviewRefreshMs` old or `maxCachedReviewBytes` evicts them; a read again after that age shows threads resolved - * since, which do not change a GitHub pull request's update time. + * `reviewRefreshMs` old, `maxCachedReviewBytes` evicts them, or a review write through this panel drops them; a read + * again after that age shows threads resolved elsewhere since, which do not change a GitHub pull request's update time. * @param session - Session the change request is bound to. * @param url - binding URL of the change request. * @param headSha - head commit of the displayed diff. @@ -493,14 +493,16 @@ export class ForgePanelController extends TypertRemoteService { const readAt = Date.now() const kept = this.reviews.get(key, version, readAt) if (kept !== undefined) return kept + const token = this.reviews.token() const review = await this.home.forge.readReview(ref, headSha, signal) if ('code' in review) throw reviewError(review) - this.reviews.set(key, version, review, readAt) + this.reviews.set(key, version, review, readAt, token) return review } /** - * Publish a line comment or completed review for a visible Session binding. + * Publish a line comment or completed review for a visible Session binding. The threads kept for the change request + * are dropped once the host answers, so the next read shows the comments. * @param session - Session retaining the change request. * @param url - visible binding URL. * @param request - reviewed head, ranges, plain text, and explicit review decision. @@ -511,13 +513,43 @@ export class ForgePanelController extends TypertRemoteService { async submitReview(session: Session, url: string, request: ForgeReviewRequest, signal: AbortSignal): Promise { signal.throwIfAborted() const { ref } = this.bound(session, url) - if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(request.headSha)) throw new Error('Invalid review commit') + if (!isCommit(request.headSha)) throw new Error('Invalid review commit') const comments = request.kind === 'comment' ? [request.comment] : request.comments - if (comments.some(comment => comment.path === '' || comment.body.trim() === '' - || !Number.isSafeInteger(comment.startLine) || comment.startLine < 1 - || !Number.isSafeInteger(comment.endLine) || comment.endLine < comment.startLine)) throw new Error('Invalid review comment') + if (!comments.every(validComment)) throw new Error('Invalid review comment') if (request.kind === 'review' && request.event !== 'approve' && request.body.trim() === '' && comments.length === 0) throw new Error('Empty review') - return await this.home.forge.submitReview(ref, request, signal) + try { + return await this.home.forge.submitReview(ref, request, signal) + } finally { + this.reviews.drop(changeRequestKey(ref)) + } + } + + /** + * Write to the review threads or the pending review of a change request of a Session while its head is still the + * head the user saw. The threads kept for the change request are dropped once the host answers, so the next read + * shows the write, which a resolve does not show through a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param write - the write and the head commit of the threads the user saw. + * @param signal - request cancellation. + * @returns whether the host took the write; `stale` once the head moved, which polls the change request again, and + * `unknown` when the outcome must be checked on the host before the write is tried again. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; an error + * for a write without text, a head that is not a commit object name, or an invalid line range. + */ + @Remote + async changeRequestReviewWrite( + session: Session, url: string, write: ForgeReviewWrite, signal: AbortSignal, + ): Promise { + signal.throwIfAborted() + const { ref } = this.bound(session, url) + if (!isCommit(write.headSha)) throw new Error('Invalid review commit') + if (!validWrite(write)) throw new Error('Invalid review write') + try { + return await this.home.forge.writeReview(ref, write, signal) + } finally { + this.reviews.drop(changeRequestKey(ref)) + } } /** @@ -851,6 +883,44 @@ export class ForgePanelController extends TypertRemoteService { } } +/** + * Whether a value names a full commit object name. + * @param value - the value from the browser. + * @returns whether it is 40 or 64 hexadecimal digits. + */ +function isCommit(value: string): boolean { + return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value) +} + +/** + * Whether a line comment from the browser names a path, consecutive positive lines, and text. + * @param comment - the comment. + * @returns whether a provider may write it. + */ +function validComment(comment: ForgeLineComment): boolean { + return comment.path !== '' && comment.body.trim() !== '' && Number.isSafeInteger(comment.startLine) && comment.startLine >= 1 + && Number.isSafeInteger(comment.endLine) && comment.endLine >= comment.startLine +} + +/** + * Whether a review write from the browser names its target and carries text where the write needs it. + * @param write - the write. + * @returns whether a provider may perform it. + */ +function validWrite(write: ForgeReviewWrite): boolean { + switch (write.kind) { + case 'reply': return write.thread !== '' && write.body.trim() !== '' + case 'resolve': return write.thread !== '' + case 'edit-comment': return write.comment !== '' && write.body.trim() !== '' + case 'delete-comment': return write.comment !== '' + case 'add-pending': return validComment(write.comment) + case 'submit-pending': return write.review !== '' && (write.event !== 'request-changes' || write.body.trim() !== '') + case 'discard-pending': return write.review !== '' + /* v8 ignore next -- closed ForgeReviewWrite kind union exhaustiveness guard */ + default: return assertNever(write, 'ForgeReviewWrite.kind') + } +} + /** * Report why the code host did not provide a diff or file. * @param failure - the provider's failure. diff --git a/packages/experimental/client-ui-forge/src/review-cache.ts b/packages/experimental/client-ui-forge/src/review-cache.ts index 6a8d9f0d1e..fa8c54f53a 100644 --- a/packages/experimental/client-ui-forge/src/review-cache.ts +++ b/packages/experimental/client-ui-forge/src/review-cache.ts @@ -1,6 +1,6 @@ /** * Review threads of change requests kept in memory, so the changes view reads them from the code host again only - * after the change request's head or update time changes or the kept threads reach their age limit. + * after the change request's head or update time changes, the kept threads reach their age limit, or a review write drops them. */ import { Buffer } from 'node:buffer' @@ -18,11 +18,14 @@ interface Entry { /** * One read of review threads per change request, bounded by age and by the total UTF-8 size of the kept reads as - * JSON; the least recently read change request leaves first. + * JSON; the least recently read change request leaves first. A read that started before a drop of any change request + * is not kept, so threads read before a write never replace the drop. */ export class ReviewCache { private readonly entries = new Map() private bytes = 0 + /** Number of drops so far. */ + private drops = 0 /** * @param maxBytes - most UTF-8 bytes kept together; a larger read is not kept. @@ -46,20 +49,26 @@ export class ReviewCache { return entry.review } + /** + * Name the cache's state when a read starts, for {@link ReviewCache.set}. + * @returns the token of the read. + */ + token(): number { + return this.drops + } + /** * Keep a read in place of the change request's earlier one, then drop the least recently read change requests - * until the rest fit within `maxBytes`. + * until the rest fit within `maxBytes`. A read whose token a drop outdated is not kept. * @param key - the change request. * @param version - head commit and update time the threads were read at. * @param review - the threads. * @param readAt - epoch milliseconds when the read started. + * @param token - what {@link ReviewCache.token} returned when the read started. */ - set(key: ChangeRequestKey, version: string, review: ForgeReviewData, readAt: number): void { - const previous = this.entries.get(key) - if (previous !== undefined) { - this.entries.delete(key) - this.bytes -= previous.bytes - } + set(key: ChangeRequestKey, version: string, review: ForgeReviewData, readAt: number, token: number): void { + if (token !== this.drops) return + this.forget(key) const bytes = Buffer.byteLength(JSON.stringify(review)) if (bytes > this.maxBytes) return this.entries.set(key, { version, review, readAt, bytes }) @@ -70,4 +79,20 @@ export class ReviewCache { this.bytes -= entry.bytes } } + + /** + * Forget the threads kept for a change request, and every read in flight, after a write changed them. + * @param key - the change request. + */ + drop(key: ChangeRequestKey): void { + this.drops += 1 + this.forget(key) + } + + private forget(key: ChangeRequestKey): void { + const previous = this.entries.get(key) + if (previous === undefined) return + this.entries.delete(key) + this.bytes -= previous.bytes + } } diff --git a/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts b/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts index de498b8bbf..119f69dffd 100644 --- a/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts @@ -8,7 +8,7 @@ const b = 'fake:b#2' as ChangeRequestKey const c = 'fake:c#3' as ChangeRequestKey function review(viewer: string): ForgeReviewData { - return { headSha: 'a'.repeat(40), viewer, threads: [], truncated: false } + return { headSha: 'a'.repeat(40), viewer, threads: [], truncated: false, pendingReview: null, viewerCanReview: false } } const size = Buffer.byteLength(JSON.stringify(review('x'))) @@ -16,7 +16,7 @@ const size = Buffer.byteLength(JSON.stringify(review('x'))) describe('ReviewCache', () => { it('returns threads kept at the same version until they reach the age limit', () => { const cache = new ReviewCache(size * 4, 100) - cache.set(a, 'v1', review('x'), 1_000) + cache.set(a, 'v1', review('x'), 1_000, cache.token()) expect(cache.get(a, 'v1', 1_099)).toEqual(review('x')) expect(cache.get(a, 'v2', 1_050)).toBeUndefined() @@ -26,26 +26,45 @@ describe('ReviewCache', () => { it('replaces a change request\'s earlier read and drops the least recently read reads past the byte limit', () => { const cache = new ReviewCache(size * 2, 100) - cache.set(a, 'v1', review('x'), 0) - cache.set(a, 'v2', review('y'), 0) - cache.set(b, 'v1', review('z'), 0) + cache.set(a, 'v1', review('x'), 0, cache.token()) + cache.set(a, 'v2', review('y'), 0, cache.token()) + cache.set(b, 'v1', review('z'), 0, cache.token()) expect(cache.get(a, 'v1', 0)).toBeUndefined() expect(cache.get(a, 'v2', 0)).toEqual(review('y')) - cache.set(c, 'v1', review('w'), 0) + cache.set(c, 'v1', review('w'), 0, cache.token()) expect(cache.get(b, 'v1', 0)).toBeUndefined() expect(cache.get(a, 'v2', 0)).toEqual(review('y')) expect(cache.get(c, 'v1', 0)).toEqual(review('w')) }) + it('forgets a dropped change request and keeps no read that started before any drop', () => { + const cache = new ReviewCache(size * 4, 100) + const early = cache.token() + cache.set(a, 'v1', review('x'), 0, early) + cache.set(b, 'v1', review('y'), 0, early) + cache.drop(a) + cache.drop(c) + + expect(cache.get(a, 'v1', 0)).toBeUndefined() + expect(cache.get(b, 'v1', 0)).toEqual(review('y')) + cache.set(a, 'v1', review('z'), 0, early) + expect(cache.get(a, 'v1', 0)).toBeUndefined() + cache.set(a, 'v1', review('z'), 0, cache.token()) + expect(cache.get(a, 'v1', 0)).toEqual(review('z')) + cache.set(a, 'v2', review('much longer than the others'), 0, cache.token()) + cache.set(c, 'v1', review('w'), 0, cache.token()) + expect(cache.get(c, 'v1', 0)).toEqual(review('w')) + }) + it('keeps no read larger than the byte limit and forgets the read it replaced', () => { const cache = new ReviewCache(size, 100) - cache.set(a, 'v1', review('x'), 0) - cache.set(a, 'v2', review('longer'), 0) + cache.set(a, 'v1', review('x'), 0, cache.token()) + cache.set(a, 'v2', review('longer'), 0, cache.token()) expect(cache.get(a, 'v1', 0)).toBeUndefined() expect(cache.get(a, 'v2', 0)).toBeUndefined() - cache.set(b, 'v1', review('x'), 0) + cache.set(b, 'v1', review('x'), 0, cache.token()) expect(cache.get(b, 'v1', 0)).toEqual(review('x')) }) }) diff --git a/packages/experimental/client-ui-forge/tests/review.host.spec.ts b/packages/experimental/client-ui-forge/tests/review.host.spec.ts index 6869d3acb6..bf34f81384 100644 --- a/packages/experimental/client-ui-forge/tests/review.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/review.host.spec.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ChangeRequestRef, ForgeFailure, ForgeReviewData, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge' +import type { + ChangeRequestRef, ForgeCommentId, ForgeFailure, ForgeReviewData, ForgeReviewId, ForgeReviewWrite, ForgeReviewWriteResult, ForgeThreadId, +} from '@deepseek-ai/dsh-experimental-forge' import { SessionId } from '@deepseek-ai/dsh-session' import ForgePanelController from '../src/index.ts' import type { ForgePanelState } from '../src/index.ts' @@ -29,10 +31,11 @@ async function opened(config: Parameters[1] = {}): Promise<{ opene function review(headSha: string, path = 'src/a.ts'): ForgeReviewData { return { - headSha, viewer: 'octo', truncated: false, + headSha, viewer: 'octo', truncated: false, pendingReview: null, viewerCanReview: true, threads: [{ id: 'thread-1' as ForgeThreadId, path, side: 'R', subject: 'line', resolved: false, diffHunk: '@@ -1 +1 @@\n-old\n+new', current: { commit: headSha, startLine: 1, endLine: 1 }, original: { commit: headSha, startLine: 1, endLine: 1 }, comments: [], + viewerCanReply: true, viewerCanResolve: true, }], } } @@ -124,6 +127,106 @@ describe('forgePanel change request reviews', () => { expect(readReview).toHaveBeenCalledTimes(codes.length) }) + it('writes through the provider, then drops the kept threads so the next read goes to the host', async () => { + const { opened: panel, follower } = await opened() + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + const writeReview = vi.fn(async (): Promise => ({ ok: true })) + Object.assign(panel.provider, { readReview, writeReview }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + const write: ForgeReviewWrite = { kind: 'resolve', headSha: HEAD, thread: 'thread-1' as ForgeThreadId, resolved: true } + + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + await expect(panel.ctx.forgePanel.changeRequestReviewWrite(panel.session, pullUrl(7), write, signal)).resolves.toEqual({ ok: true }) + expect(writeReview).toHaveBeenCalledWith(expect.objectContaining({ number: 7 }), write, expect.any(AbortSignal)) + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + expect(readReview).toHaveBeenCalledTimes(2) + + writeReview.mockRejectedValueOnce(new Error('gh crashed')) + await expect(panel.ctx.forgePanel.changeRequestReviewWrite(panel.session, pullUrl(7), write, signal)).rejects.toThrow('gh crashed') + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + expect(readReview).toHaveBeenCalledTimes(3) + }) + + it('keeps no threads read before a write that settled during the read', async () => { + const { opened: panel, follower } = await opened() + const reading = Promise.withResolvers() + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + readReview.mockReturnValueOnce(reading.promise) + Object.assign(panel.provider, { readReview, writeReview: async (): Promise => ({ ok: true }) }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + const first = panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + await vi.waitFor(() => { expect(readReview).toHaveBeenCalledTimes(1) }) + await panel.ctx.forgePanel.changeRequestReviewWrite(panel.session, pullUrl(7), + { kind: 'delete-comment', headSha: HEAD, comment: 'comment-1' as ForgeCommentId }, signal) + reading.resolve(review(HEAD, 'src/before.ts')) + await expect(first).resolves.toMatchObject({ threads: [{ path: 'src/before.ts' }] }) + await expect(panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal)) + .resolves.toMatchObject({ threads: [{ path: 'src/a.ts' }] }) + expect(readReview).toHaveBeenCalledTimes(2) + }) + + it('reports a provider without review writes as unsupported and refuses an unbound change request and invalid writes', async () => { + const { opened: panel, follower } = await opened() + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + const write = (url: string, value: ForgeReviewWrite): Promise => + panel.ctx.forgePanel.changeRequestReviewWrite(panel.session, url, value, signal) + const thread = 'thread-1' as ForgeThreadId + const comment = 'comment-1' as ForgeCommentId + const reviewId = 'review-1' as ForgeReviewId + const line = { path: 'src/a.ts', side: 'R' as const, startLine: 2, endLine: 3, body: 'Check' } + + await expect(write(pullUrl(7), { kind: 'resolve', headSha: HEAD, thread, resolved: false })) + .resolves.toEqual({ ok: false, code: 'unsupported', message: 'This provider does not support review writes' }) + await expect(write(pullUrl(9), { kind: 'resolve', headSha: HEAD, thread, resolved: false })).rejects.toMatchObject({ code: 'forgePanel/not-bound' }) + await expect(write(pullUrl(7), { kind: 'resolve', headSha: 'main', thread, resolved: false })).rejects.toThrow('Invalid review commit') + const invalid: ForgeReviewWrite[] = [ + { kind: 'reply', headSha: HEAD, thread, body: ' ', pending: false }, + { kind: 'reply', headSha: HEAD, thread: '' as ForgeThreadId, body: 'Done', pending: false }, + { kind: 'resolve', headSha: HEAD, thread: '' as ForgeThreadId, resolved: true }, + { kind: 'edit-comment', headSha: HEAD, comment, body: '' }, + { kind: 'edit-comment', headSha: HEAD, comment: '' as ForgeCommentId, body: 'Text' }, + { kind: 'delete-comment', headSha: HEAD, comment: '' as ForgeCommentId }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, path: '' } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, body: '\n' } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, startLine: 0 } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, startLine: 1.5 } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, endLine: 1 } }, + { kind: 'add-pending', headSha: HEAD, comment: { ...line, endLine: Number.POSITIVE_INFINITY } }, + { kind: 'submit-pending', headSha: HEAD, review: '' as ForgeReviewId, event: 'comment', body: '' }, + { kind: 'submit-pending', headSha: HEAD, review: reviewId, event: 'request-changes', body: ' ' }, + { kind: 'discard-pending', headSha: HEAD, review: '' as ForgeReviewId }, + ] + for (const value of invalid) await expect(write(pullUrl(7), value)).rejects.toThrow('Invalid review write') + const valid: ForgeReviewWrite[] = [ + { kind: 'reply', headSha: HEAD, thread, body: 'Done', pending: true }, + { kind: 'edit-comment', headSha: HEAD, comment, body: 'Text' }, + { kind: 'delete-comment', headSha: HEAD, comment }, + { kind: 'add-pending', headSha: HEAD, comment: line }, + { kind: 'submit-pending', headSha: HEAD, review: reviewId, event: 'approve', body: '' }, + { kind: 'submit-pending', headSha: HEAD, review: reviewId, event: 'request-changes', body: 'Fix it' }, + { kind: 'discard-pending', headSha: HEAD, review: reviewId }, + ] + for (const value of valid) await expect(write(pullUrl(7), value)).resolves.toMatchObject({ ok: false, code: 'unsupported' }) + }) + + it('drops the kept threads after publishing a comment so the next read shows it', async () => { + const { opened: panel, follower } = await opened() + const readReview = vi.fn(async (_ref: ChangeRequestRef, headSha: string): Promise => review(headSha)) + Object.assign(panel.provider, { readReview, submitReview: async () => ({ ok: true as const, url: `${pullUrl(7)}#discussion_r1` }) }) + shellTurn(panel.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }]) + await follower.until(state => polledAt(state, pullUrl(7))) + + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + await panel.ctx.forgePanel.submitReview(panel.session, pullUrl(7), + { kind: 'comment', headSha: HEAD, comment: { path: 'src/a.ts', side: 'R', startLine: 1, endLine: 1, body: 'Nit' } }, signal) + await panel.ctx.forgePanel.changeRequestReview(panel.session, pullUrl(7), HEAD, signal) + expect(readReview).toHaveBeenCalledTimes(2) + }) + it.each([ [{ reviewRefreshMs: 0 }, 'forge-panel: reviewRefreshMs must be a positive integer no greater than 2147483647'], [{ reviewRefreshMs: 2_147_483_648 }, 'forge-panel: reviewRefreshMs must be a positive integer no greater than 2147483647'], diff --git a/packages/experimental/forge-github/package.json b/packages/experimental/forge-github/package.json index d0270615b4..9d9fcc6391 100644 --- a/packages/experimental/forge-github/package.json +++ b/packages/experimental/forge-github/package.json @@ -34,6 +34,7 @@ "dependencies": { "@deepseek-ai/schemastery": "workspace:~", "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "execa": "^10.0.1", "zod": "4.6.6" }, diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index cf6905149e..806ef46d7b 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -3,7 +3,8 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeCommitList, ForgeFailure, ForgeProvider, - ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, MergeRequest, + ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, ForgeReviewWrite, ForgeReviewWriteResult, + MergeRequest, } from '@deepseek-ai/dsh-experimental-forge' import { listCommits } from './commits.ts' import type { GraphqlReader } from './commits.ts' @@ -17,6 +18,7 @@ import { import type { PageResult, SnapshotPages } from './graphql.ts' import { checkedRepository, isCommit, parsePullRequestUrl, parseRemoteUrl } from './urls.ts' import { submitGitHubReview } from './review.ts' +import { writeGitHubReview } from './review-writes.ts' import { readReviewThreads } from './threads.ts' const AUTHENTICATION = /gh auth login|authentication|bad credentials|HTTP 401/i @@ -65,6 +67,17 @@ export class GitHubProvider implements ForgeProvider { return await readReviewThreads(this.graphql, ref, headSha, this.limits, signal) } + /** + * Write to the review threads or the pending review of a pull request through GraphQL mutations after a check of its head. + * @see writeGitHubReview + */ + async writeReview(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise { + const failure = this.hostFailure(ref.repository.host) + if (failure !== undefined) return { ok: false, code: 'failed', message: failure.message } + if (!isCommit(write.headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(write.headSha)}`) + return await writeGitHubReview(this.graphql, this.gh, ref, write, signal) + } + /** * @param gh - runner that executes `gh` on the host. * @param hosts - host names this provider serves, such as `github.com` and Enterprise hosts. diff --git a/packages/experimental/forge-github/src/review-writes.ts b/packages/experimental/forge-github/src/review-writes.ts new file mode 100644 index 0000000000..d2c6835512 --- /dev/null +++ b/packages/experimental/forge-github/src/review-writes.ts @@ -0,0 +1,209 @@ +/** GitHub writes to review threads and the pending review through GraphQL mutations, each after a check of the head. */ + +import type { + ChangeRequestRef, ForgeLineComment, ForgeReviewId, ForgeReviewWrite, ForgeReviewWriteResult, +} from '@deepseek-ai/dsh-experimental-forge' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { z } from 'zod' +import type { GraphqlReader } from './commits.ts' +import { GhUnavailableError, type GhOutput, type GhRunner } from './gh.ts' +import { checkedRepository } from './urls.ts' + +/** The pull request, its head, and the signed-in user's pending review; variables `owner` and `name` are strings, `number` an integer. */ +export const WRITE_CHECK_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { id headRefOid reviews(states: PENDING, first: 1) { nodes { id viewerDidAuthor } } } + } +}` + +/** Start a pending review at a commit, with the line threads of `threads`. */ +export const ADD_REVIEW_MUTATION = `mutation($pull: ID!, $commit: GitObjectID!, $threads: [DraftPullRequestReviewThread]) { + addPullRequestReview(input: { pullRequestId: $pull, commitOID: $commit, threads: $threads }) { pullRequestReview { id } } +}` + +/** Add one line thread to a pending review. */ +export const ADD_THREAD_MUTATION = `mutation($review: ID!, $path: String!, $body: String!, $line: Int!, $side: DiffSide!, $startLine: Int, $startSide: DiffSide) { + addPullRequestReviewThread(input: { + pullRequestReviewId: $review, path: $path, body: $body, line: $line, side: $side, startLine: $startLine, startSide: $startSide + }) { thread { id } } +}` + +/** Reply to a thread, inside the pending review `review` when it is set. */ +export const REPLY_MUTATION = `mutation($thread: ID!, $body: String!, $review: ID) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $thread, body: $body, pullRequestReviewId: $review }) { comment { id } } +}` + +/** Mark a thread resolved. */ +export const RESOLVE_MUTATION = 'mutation($thread: ID!) { resolveReviewThread(input: { threadId: $thread }) { thread { id } } }' + +/** Mark a thread unresolved. */ +export const UNRESOLVE_MUTATION = 'mutation($thread: ID!) { unresolveReviewThread(input: { threadId: $thread }) { thread { id } } }' + +/** Replace the text of a review comment. */ +export const EDIT_COMMENT_MUTATION = `mutation($comment: ID!, $body: String!) { + updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $comment, body: $body }) { pullRequestReviewComment { id } } +}` + +/** Delete a review comment. */ +export const DELETE_COMMENT_MUTATION = 'mutation($comment: ID!) { deletePullRequestReviewComment(input: { id: $comment }) { clientMutationId } }' + +/** Submit a pending review with a decision and summary. */ +export const SUBMIT_REVIEW_MUTATION = `mutation($review: ID!, $event: PullRequestReviewEvent!, $body: String) { + submitPullRequestReview(input: { pullRequestReviewId: $review, event: $event, body: $body }) { pullRequestReview { id } } +}` + +/** Delete a pending review with its comments. */ +export const DELETE_REVIEW_MUTATION = 'mutation($review: ID!) { deletePullRequestReview(input: { pullRequestReviewId: $review }) { pullRequestReview { id } } }' + +const EVENTS = { comment: 'COMMENT', approve: 'APPROVE', 'request-changes': 'REQUEST_CHANGES' } as const + +/** HTTP statuses that mean GitHub refused the request, so it changed nothing. */ +const REFUSED = /\(HTTP (?:400|401|403|404|409|422)\)/ + +const checkSchema = z.object({ data: z.object({ repository: z.object({ pullRequest: z.object({ + id: z.string(), + headRefOid: z.string(), + reviews: z.object({ nodes: z.array(z.object({ id: z.string(), viewerDidAuthor: z.boolean() })) }), +}) }) }) }) + +const graphqlErrors = z.object({ errors: z.array(z.object({ message: z.string() })).min(1) }) + +/** One mutation and the payload field that holds its result. */ +interface Mutation { + readonly query: string + readonly field: string + readonly variables: Readonly> +} + +/** A mutation's result: the payload on success, else the write's outcome. */ +type Sent = { readonly ok: true; readonly payload: unknown } | Extract + +const stringId = z.object({ id: z.string() }) + +/** + * Write to the review threads or the pending review of a pull request. One read first checks that the head is still + * `write.headSha` and finds the signed-in user's pending review; a moved head returns `stale` without writing. A pending + * reply or comment without a pending review starts one at `write.headSha`, so it takes two mutations. A reply at once is + * refused with `failed` while the user has a pending review, since GitHub would add it to that review. Mutations send + * their variables as JSON on stdin, and none is retried: a refusal GitHub reports returns `failed`, and an answer that + * does not show whether GitHub applied a mutation returns `unknown`. + * @param read - runs the check as one GraphQL request. + * @param gh - bounded, cancellable GitHub CLI runner for the mutations. + * @param ref - pull request, with an owner and name already checked for inlining. + * @param write - the write and the head commit the user saw. + * @param signal - cancellation of the check and the mutations. + * @returns whether GitHub took the write. + * @throws with `signal.reason` when the signal aborts before a mutation is sent. + */ +export async function writeGitHubReview( + read: GraphqlReader, gh: GhRunner, ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal, +): Promise { + const { host, owner, name } = checkedRepository(ref) + const checked = await read(['api', 'graphql', '--hostname', host, + '-f', `query=${WRITE_CHECK_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`], signal) + if (!checked.ok) return { ok: false, code: 'failed', message: checked.failure.message } + const parsed = checkSchema.safeParse(checked.body) + if (!parsed.success) return { ok: false, code: 'failed', message: 'unexpected GitHub response to the pull request check' } + const pull = parsed.data.data.repository.pullRequest + if (pull.headRefOid.toLowerCase() !== write.headSha.toLowerCase()) { + return { ok: false, code: 'stale', message: `the pull request head moved from ${write.headSha} to ${pull.headRefOid}` } + } + const pending = pull.reviews.nodes.find(review => review.viewerDidAuthor)?.id ?? null + signal.throwIfAborted() + const send = async (mutation: Mutation): Promise => await sendMutation(gh, host, mutation, signal) + const done = (sent: Sent): ForgeReviewWriteResult => sent.ok ? { ok: true } : sent + /** Find the pending review, starting one at the head without threads when the user has none. */ + const pendingReview = async (): Promise<{ readonly ok: true; readonly id: string } | Extract> => { + if (pending !== null) return { ok: true, id: pending } + const started = await send({ query: ADD_REVIEW_MUTATION, field: 'addPullRequestReview', + variables: { pull: pull.id, commit: write.headSha, threads: null } }) + if (!started.ok) return started + const review = z.object({ pullRequestReview: stringId }).safeParse(started.payload) + return review.success ? { ok: true, id: review.data.pullRequestReview.id } + : { ok: false, code: 'unknown', message: 'unexpected GitHub response to starting a pending review' } + } + switch (write.kind) { + case 'reply': { + // GitHub adds every reply of a user with a pending review to that review, even one sent without its id. + if (!write.pending && pending !== null) { + return { ok: false, code: 'failed', message: 'GitHub adds a reply to the pending review while one exists; submit or discard it first' } + } + const review = write.pending ? await pendingReview() : { ok: true as const, id: null } + if (!review.ok) return review + return done(await send({ query: REPLY_MUTATION, field: 'addPullRequestReviewThreadReply', + variables: { thread: write.thread, body: write.body, review: review.id } })) + } + case 'resolve': + return done(await send(write.resolved + ? { query: RESOLVE_MUTATION, field: 'resolveReviewThread', variables: { thread: write.thread } } + : { query: UNRESOLVE_MUTATION, field: 'unresolveReviewThread', variables: { thread: write.thread } })) + case 'edit-comment': + return done(await send({ query: EDIT_COMMENT_MUTATION, field: 'updatePullRequestReviewComment', + variables: { comment: write.comment, body: write.body } })) + case 'delete-comment': + return done(await send({ query: DELETE_COMMENT_MUTATION, field: 'deletePullRequestReviewComment', variables: { comment: write.comment } })) + case 'add-pending': + return done(await send(pending === null + ? { query: ADD_REVIEW_MUTATION, field: 'addPullRequestReview', + variables: { pull: pull.id, commit: write.headSha, threads: [draftThread(write.comment)] } } + : { query: ADD_THREAD_MUTATION, field: 'addPullRequestReviewThread', variables: { review: pending, ...draftThread(write.comment) } })) + case 'submit-pending': + if (pending !== write.review) return missingReview(write.review) + return done(await send({ query: SUBMIT_REVIEW_MUTATION, field: 'submitPullRequestReview', + variables: { review: write.review, event: EVENTS[write.event], body: write.body } })) + case 'discard-pending': + if (pending !== write.review) return missingReview(write.review) + return done(await send({ query: DELETE_REVIEW_MUTATION, field: 'deletePullRequestReview', variables: { review: write.review } })) + /* v8 ignore next -- closed ForgeReviewWrite kind union exhaustiveness guard */ + default: return assertNever(write, 'ForgeReviewWrite.kind') + } +} + +/** The fields of a line thread in a pending review, for one side of the diff. */ +function draftThread(comment: ForgeLineComment): Readonly> { + const side = comment.side === 'L' ? 'LEFT' : 'RIGHT' + return { path: comment.path, body: comment.body, line: comment.endLine, side, + ...comment.startLine === comment.endLine ? { startLine: null, startSide: null } : { startLine: comment.startLine, startSide: side } } +} + +function missingReview(review: ForgeReviewId): ForgeReviewWriteResult { + return { ok: false, code: 'failed', message: `the pending review ${review} is no longer the signed-in user's pending review` } +} + +/** + * Send one mutation with its query and variables as JSON on stdin. + * @returns the payload of `mutation.field`; `failed` when GitHub reports a refusal or `gh` cannot start, else `unknown`. + */ +async function sendMutation(gh: GhRunner, host: string, mutation: Mutation, signal: AbortSignal): Promise { + let output: GhOutput + try { + output = await gh(['api', 'graphql', '--hostname', host, '--input', '-'], signal, + JSON.stringify({ query: mutation.query, variables: mutation.variables })) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, code: error instanceof GhUnavailableError ? 'failed' : 'unknown', message } + } + const body = parse(output.stdout) + // A payload means GitHub applied the mutation, even beside errors about the fields it selects. + const payload = z.object({ data: z.record(z.string(), z.unknown()) }).safeParse(body) + const value = payload.success ? payload.data.data[mutation.field] : undefined + if (value !== undefined && value !== null) return { ok: true, payload: value } + const errors = graphqlErrors.safeParse(body) + if (errors.success) return { ok: false, code: 'failed', message: errors.data.errors.map(error => error.message).join('; ') } + if (output.exitCode !== 0) { + const message = output.stderr.trim() || output.stdout.trim() || `gh exited with ${output.exitCode}` + return { ok: false, code: REFUSED.test(output.stderr) ? 'failed' : 'unknown', message } + } + return { ok: false, code: 'unknown', message: `unexpected GitHub response to ${mutation.field}` } +} + +function parse(text: string): unknown { + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + void error // gh prints diagnostics instead of JSON when a request fails before GitHub answers. + return undefined + } + return value +} diff --git a/packages/experimental/forge-github/src/threads.ts b/packages/experimental/forge-github/src/threads.ts index 5717f47a0c..6b1e6096b3 100644 --- a/packages/experimental/forge-github/src/threads.ts +++ b/packages/experimental/forge-github/src/threads.ts @@ -2,7 +2,8 @@ import { Buffer } from 'node:buffer' import type { - ChangeRequestRef, ForgeAnchor, ForgeCommentId, ForgeFailure, ForgeReviewComment, ForgeReviewData, ForgeReviewThread, ForgeThreadId, + ChangeRequestRef, ForgeAnchor, ForgeCommentId, ForgeFailure, ForgeReviewComment, ForgeReviewData, ForgeReviewId, ForgeReviewThread, + ForgeThreadId, } from '@deepseek-ai/dsh-experimental-forge' import { z } from 'zod' import type { GraphqlReader } from './commits.ts' @@ -11,7 +12,10 @@ import { checkedRepository, isCommit } from './urls.ts' /** Most nodes GitHub returns in one connection page. */ const PAGE_SIZE = 100 -const COMMENT_FIELDS = 'id state createdAt url body author { __typename login avatarUrl(size: 48) }' +/** Repository permissions that may comment on a locked conversation. */ +const WRITERS: ReadonlySet = new Set(['ADMIN', 'MAINTAIN', 'WRITE']) + +const COMMENT_FIELDS = 'id state createdAt url body viewerCanUpdate viewerCanDelete author { __typename login avatarUrl(size: 48) }' const COMMENT_PAGE = `comments(first: ${PAGE_SIZE}, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT_FIELDS} } }` @@ -20,15 +24,26 @@ const COMMENT_PAGE = `comments(first: ${PAGE_SIZE}, after: $cursor) { pageInfo { * first page of comments starts without a cursor. */ const THREAD_FIELDS = `id isOutdated isResolved path diffSide startDiffSide line startLine originalLine originalStartLine subjectType + viewerCanReply viewerCanResolve viewerCanUnresolve root: comments(first: 1) { nodes { diffHunk commit { oid } originalCommit { oid } } } ${COMMENT_PAGE.replace(', after: $cursor', '')}` const THREAD_PAGE = `pageInfo { hasNextPage endCursor } nodes { ${THREAD_FIELDS} }` -/** First thread page with the head and the viewer; variables `owner` and `name` are strings, `number` and `first` integers. */ +/** + * First thread page with the head, the viewer, whether the viewer may comment, and the viewer's pending review; variables + * `owner` and `name` are strings, `number` and `first` integers. + */ export const REVIEW_QUERY = `query($owner: String!, $name: String!, $number: Int!, $first: Int!) { viewer { login } - repository(owner: $owner, name: $name) { pullRequest(number: $number) { id headRefOid reviewThreads(first: $first) { ${THREAD_PAGE} } } } + repository(owner: $owner, name: $name) { + isArchived viewerPermission + pullRequest(number: $number) { + id headRefOid locked + reviews(states: PENDING, first: 1) { nodes { id viewerDidAuthor commit { oid } comments { totalCount } } } + reviewThreads(first: $first) { ${THREAD_PAGE} } + } + } }` /** Following thread page with the head it was read at; variables `id` and `cursor` are strings, `first` an integer. */ @@ -62,6 +77,8 @@ const commentSchema = z.object({ createdAt: z.string(), url: z.string().nullable(), body: z.string(), + viewerCanUpdate: z.boolean(), + viewerCanDelete: z.boolean(), author: z.object({ __typename: z.string(), login: z.string(), avatarUrl: z.string().nullable() }).nullable(), }) @@ -79,15 +96,35 @@ const threadSchema = z.object({ originalLine: z.number().int().nullable(), originalStartLine: z.number().int().nullable(), subjectType: z.string(), + viewerCanReply: z.boolean(), + viewerCanResolve: z.boolean(), + viewerCanUnresolve: z.boolean(), root: z.object({ nodes: z.array(z.object({ diffHunk: z.string(), commit: commitSchema, originalCommit: commitSchema })) }), comments: commentPageSchema, }) const threadPageSchema = z.object({ pageInfo: pageInfoSchema, nodes: z.array(threadSchema) }) +const pendingSchema = z.object({ + id: z.string(), + viewerDidAuthor: z.boolean(), + commit: commitSchema, + comments: z.object({ totalCount: z.number().int().nonnegative() }), +}) + const firstPageSchema = z.object({ data: z.object({ viewer: z.object({ login: z.string() }).nullable(), - repository: z.object({ pullRequest: z.object({ id: z.string(), headRefOid: z.string(), reviewThreads: threadPageSchema }) }), + repository: z.object({ + isArchived: z.boolean(), + viewerPermission: z.string().nullable(), + pullRequest: z.object({ + id: z.string(), + headRefOid: z.string(), + locked: z.boolean(), + reviews: z.object({ nodes: z.array(pendingSchema) }), + reviewThreads: threadPageSchema, + }), + }), }) }) const nextPageSchema = z.object({ data: z.object({ node: z.object({ headRefOid: z.string(), reviewThreads: threadPageSchema }) }) }) @@ -127,14 +164,20 @@ export async function readReviewThreads( if ('code' in first) return first const parsed = firstPageSchema.safeParse(first.body) if (!parsed.success) return unexpected - const pull = parsed.data.data.repository.pullRequest + const repository = parsed.data.data.repository + const pull = repository.pullRequest const moved = stale(pull.headRefOid) if (moved !== undefined) return moved let bytes = first.bytes let page = pull.reviewThreads const threads: ForgeReviewThread[] = [] const viewer = parsed.data.data.viewer?.login ?? null - const result = (truncated: boolean): ForgeReviewData => ({ headSha, viewer, threads, truncated }) + const pending = pull.reviews.nodes.find(review => review.viewerDidAuthor) + const pendingReview = pending?.commit == null ? null + : { id: pending.id as ForgeReviewId, commit: pending.commit.oid, comments: pending.comments.totalCount } + // GitHub refuses every write to an archived repository, and comments on a locked conversation from anyone without write access. + const viewerCanReview = !repository.isArchived && (!pull.locked || WRITERS.has(repository.viewerPermission ?? '')) + const result = (truncated: boolean): ForgeReviewData => ({ headSha, viewer, threads, truncated, pendingReview, viewerCanReview }) const cursors = new Set() for (;;) { for (const node of page.nodes.slice(0, limits.maxReviewThreads - threads.length)) { @@ -193,6 +236,8 @@ function toThread(node: ThreadNode, comments: readonly CommentNode[], head: stri ? anchor(root.originalCommit.oid, sameSide ? node.originalStartLine : null, node.originalLine) : null, diffHunk: root === undefined || root.diffHunk === '' ? null : root.diffHunk, resolved: node.isResolved, + viewerCanReply: node.viewerCanReply, + viewerCanResolve: node.isResolved ? node.viewerCanUnresolve : node.viewerCanResolve, comments: comments.map(toComment), } } @@ -209,5 +254,7 @@ function toComment(node: CommentNode): ForgeReviewComment { createdAt: node.createdAt, pending: node.state === 'PENDING', url: node.url, + viewerCanEdit: node.viewerCanUpdate, + viewerCanDelete: node.viewerCanDelete, } } diff --git a/packages/experimental/forge-github/tests/review-writes.spec.ts b/packages/experimental/forge-github/tests/review-writes.spec.ts new file mode 100644 index 0000000000..d620401e36 --- /dev/null +++ b/packages/experimental/forge-github/tests/review-writes.spec.ts @@ -0,0 +1,253 @@ +/** GitHub review thread and pending review writes check the head first, send JSON on stdin, and never retry. */ +import { describe, expect, it } from 'vitest' +import type { ForgeCommentId, ForgeReviewId, ForgeReviewWrite, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge' +import { GhTimeoutError, GhUnavailableError, type GhOutput, type GhRunner } from '../src/gh.ts' +import { GitHubProvider } from '../src/provider.ts' +import { + ADD_REVIEW_MUTATION, ADD_THREAD_MUTATION, DELETE_COMMENT_MUTATION, DELETE_REVIEW_MUTATION, EDIT_COMMENT_MUTATION, REPLY_MUTATION, + RESOLVE_MUTATION, SUBMIT_REVIEW_MUTATION, UNRESOLVE_MUTATION, WRITE_CHECK_QUERY, +} from '../src/review-writes.ts' +import { demo, pull } from './fixtures.ts' + +const head = 'c'.repeat(40) +const pushed = 'd'.repeat(40) +const signal = new AbortController().signal +const thread = 'thread-1' as ForgeThreadId +const comment = 'comment-1' as ForgeCommentId +const review = 'review-1' as ForgeReviewId + +function output(stdout: unknown, exitCode = 0, stderr = ''): GhOutput { + return { exitCode, stdout: typeof stdout === 'string' ? stdout : JSON.stringify(stdout), stderr } +} + +function checked(pending: string | null = null, headRefOid = head, viewerDidAuthor = true): GhOutput { + return output({ data: { repository: { pullRequest: { + id: 'pull-7', headRefOid, reviews: { nodes: pending === null ? [] : [{ id: pending, viewerDidAuthor }] }, + } } } }) +} + +/** One recorded `gh` call: its arguments and, for a mutation, the JSON it sent on stdin. */ +interface Call { + readonly args: readonly string[] + readonly input: { readonly query: string; readonly variables: Record } | undefined +} + +/** A mutation answer that throws `throws` instead of printing output. */ +interface Thrown { readonly throws: unknown } + +/** Answer the check with `check`, then each mutation in turn from `mutations`. */ +function fakeGh(check: GhOutput, ...mutations: Array): { run: GhRunner; calls: Call[] } { + const calls: Call[] = [] + const queue = [...mutations] + return { calls, run: async (args, _signal, input) => { + calls.push({ args: [...args], input: input === undefined ? undefined : JSON.parse(input) as Call['input'] }) + if (input === undefined) return check + const next = queue.shift() + if (next === undefined) throw new Error(`unexpected gh ${args.join(' ')}`) + if ('throws' in next) throw next.throws + return next + } } +} + +function provider(run: GhRunner): GitHubProvider { + return new GitHubProvider(run, ['github.com'], { maxOutputBytes: 8_388_608, maxReviewThreads: 1_000, maxCommits: 250 }) +} + +function payload(field: string, value: unknown = { id: 'x' }): GhOutput { + return output({ data: { [field]: value } }) +} + +const MUTATION_ARGS = ['api', 'graphql', '--hostname', 'github.com', '--input', '-'] + +describe('GitHub review writes', () => { + it('checks the head with the pull request as variables and replies at once without a pending review', async () => { + const gh = fakeGh(checked(), payload('addPullRequestReviewThreadReply', { comment: { id: 'reply-1' } })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'reply', headSha: head, thread, body: 'Fixed in `$(x)`', pending: false }, signal)) + .resolves.toEqual({ ok: true }) + expect(gh.calls).toEqual([ + { args: ['api', 'graphql', '--hostname', 'github.com', '-f', `query=${WRITE_CHECK_QUERY}`, '-f', `owner=${demo.owner}`, + '-f', `name=${demo.name}`, '-F', 'number=7'], input: undefined }, + { args: MUTATION_ARGS, input: { query: REPLY_MUTATION, variables: { thread, body: 'Fixed in `$(x)`', review: null } } }, + ]) + }) + + it('refuses a reply at once without a mutation while the viewer has a pending review, which GitHub would add it to', async () => { + const gh = fakeGh(checked('review-9')) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'reply', headSha: head, thread, body: 'Now', pending: false }, signal)) + .resolves.toEqual({ ok: false, code: 'failed', + message: 'GitHub adds a reply to the pending review while one exists; submit or discard it first' }) + expect(gh.calls).toHaveLength(1) + }) + + it('replies inside the existing pending review', async () => { + const gh = fakeGh(checked('review-9'), payload('addPullRequestReviewThreadReply')) + + await provider(gh.run).writeReview(pull(7), { kind: 'reply', headSha: head, thread, body: 'Later', pending: true }, signal) + + expect(gh.calls[1]?.input).toEqual({ query: REPLY_MUTATION, variables: { thread, body: 'Later', review: 'review-9' } }) + }) + + it('starts a pending review at the head before a pending reply when the viewer has none', async () => { + const gh = fakeGh(checked('review-of-other', head, false), + payload('addPullRequestReview', { pullRequestReview: { id: 'review-new' } }), payload('addPullRequestReviewThreadReply')) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'reply', headSha: head, thread, body: 'Later', pending: true }, signal)) + .resolves.toEqual({ ok: true }) + expect(gh.calls.slice(1).map(call => call.input)).toEqual([ + { query: ADD_REVIEW_MUTATION, variables: { pull: 'pull-7', commit: head, threads: null } }, + { query: REPLY_MUTATION, variables: { thread, body: 'Later', review: 'review-new' } }, + ]) + }) + + it('returns the failure of starting a pending review and an unexpected start without replying', async () => { + const write: ForgeReviewWrite = { kind: 'reply', headSha: head, thread, body: 'Later', pending: true } + const refused = fakeGh(checked(), output({ data: { addPullRequestReview: null }, errors: [{ message: 'Pull request is locked' }] }, 1)) + await expect(provider(refused.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'Pull request is locked' }) + expect(refused.calls).toHaveLength(2) + + const odd = fakeGh(checked(), payload('addPullRequestReview', { pullRequestReview: null })) + await expect(provider(odd.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'unknown', message: 'unexpected GitHub response to starting a pending review' }) + expect(odd.calls).toHaveLength(2) + }) + + it.each([ + [true, RESOLVE_MUTATION, 'resolveReviewThread'], + [false, UNRESOLVE_MUTATION, 'unresolveReviewThread'], + ] as const)('marks a thread resolved %s through its mutation', async (resolved, query, field) => { + const gh = fakeGh(checked(), payload(field, { thread: { id: thread } })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'resolve', headSha: head, thread, resolved }, signal)).resolves.toEqual({ ok: true }) + expect(gh.calls[1]?.input).toEqual({ query, variables: { thread } }) + }) + + it('edits and deletes a comment', async () => { + const edit = fakeGh(checked(), payload('updatePullRequestReviewComment')) + await provider(edit.run).writeReview(pull(7), { kind: 'edit-comment', headSha: head, comment, body: 'Reworded' }, signal) + expect(edit.calls[1]?.input).toEqual({ query: EDIT_COMMENT_MUTATION, variables: { comment, body: 'Reworded' } }) + + const remove = fakeGh(checked(), payload('deletePullRequestReviewComment', { clientMutationId: null })) + await expect(provider(remove.run).writeReview(pull(7), { kind: 'delete-comment', headSha: head, comment }, signal)).resolves.toEqual({ ok: true }) + expect(remove.calls[1]?.input).toEqual({ query: DELETE_COMMENT_MUTATION, variables: { comment } }) + }) + + it('starts a pending review at the head holding a new comment when the viewer has none', async () => { + const gh = fakeGh(checked(), payload('addPullRequestReview', { pullRequestReview: { id: 'review-new' } })) + + await provider(gh.run).writeReview(pull(7), { kind: 'add-pending', headSha: head, + comment: { path: 'src/a.ts', side: 'L', startLine: 3, endLine: 5, body: 'Range' } }, signal) + + expect(gh.calls[1]?.input).toEqual({ query: ADD_REVIEW_MUTATION, variables: { pull: 'pull-7', commit: head, threads: [ + { path: 'src/a.ts', body: 'Range', line: 5, side: 'LEFT', startLine: 3, startSide: 'LEFT' }, + ] } }) + }) + + it('adds a new single-line comment to the existing pending review', async () => { + const gh = fakeGh(checked('review-9'), payload('addPullRequestReviewThread', { thread: { id: 'thread-new' } })) + + await provider(gh.run).writeReview(pull(7), { kind: 'add-pending', headSha: head, + comment: { path: 'src/a.ts', side: 'R', startLine: 8, endLine: 8, body: 'Line' } }, signal) + + expect(gh.calls[1]?.input).toEqual({ query: ADD_THREAD_MUTATION, variables: { + review: 'review-9', path: 'src/a.ts', body: 'Line', line: 8, side: 'RIGHT', startLine: null, startSide: null, + } }) + }) + + it.each([ + ['comment', 'COMMENT'], ['approve', 'APPROVE'], ['request-changes', 'REQUEST_CHANGES'], + ] as const)('submits the pending review as a %s review', async (event, graphqlEvent) => { + const gh = fakeGh(checked(review), payload('submitPullRequestReview', { pullRequestReview: { id: review } })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'submit-pending', headSha: head, review, event, body: 'Summary' }, signal)) + .resolves.toEqual({ ok: true }) + expect(gh.calls[1]?.input).toEqual({ query: SUBMIT_REVIEW_MUTATION, variables: { review, event: graphqlEvent, body: 'Summary' } }) + }) + + it('discards the pending review', async () => { + const gh = fakeGh(checked(review), payload('deletePullRequestReview', { pullRequestReview: { id: review } })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'discard-pending', headSha: head, review }, signal)).resolves.toEqual({ ok: true }) + expect(gh.calls[1]?.input).toEqual({ query: DELETE_REVIEW_MUTATION, variables: { review } }) + }) + + it.each([ + ['submit', { kind: 'submit-pending', headSha: head, review, event: 'comment', body: '' }], + ['discard', { kind: 'discard-pending', headSha: head, review }], + ] as const)('refuses to %s a pending review that is no longer the viewer\'s without a mutation', async (_label, write) => { + for (const pending of [null, 'review-other']) { + const gh = fakeGh(checked(pending)) + await expect(provider(gh.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'the pending review review-1 is no longer the signed-in user\'s pending review' }) + expect(gh.calls).toHaveLength(1) + } + }) + + it('refuses a moved head without a mutation and accepts a head in another case', async () => { + const write: ForgeReviewWrite = { kind: 'resolve', headSha: head, thread, resolved: true } + const moved = fakeGh(checked(null, pushed)) + await expect(provider(moved.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }) + expect(moved.calls).toHaveLength(1) + + const upper = fakeGh(checked(null, head.toUpperCase()), payload('resolveReviewThread')) + await expect(provider(upper.run).writeReview(pull(7), write, signal)).resolves.toEqual({ ok: true }) + }) + + it('reports a failed or unexpected check as failed without a mutation', async () => { + const write: ForgeReviewWrite = { kind: 'resolve', headSha: head, thread, resolved: true } + const down = fakeGh(output('', 1, 'gh: Server Error (HTTP 502)')) + await expect(provider(down.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'gh: Server Error (HTTP 502)' }) + + const odd = fakeGh(output({ data: { repository: { pullRequest: null } } })) + await expect(provider(odd.run).writeReview(pull(7), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'unexpected GitHub response to the pull request check' }) + expect([down.calls, odd.calls].map(calls => calls.length)).toEqual([1, 1]) + }) + + it.each([ + ['a gh that cannot start', { throws: new GhUnavailableError('gh could not start: ENOENT') }, 'failed', 'gh could not start: ENOENT'], + ['a gh that times out', { throws: new GhTimeoutError('gh api timed out after 30000ms') }, 'unknown', 'gh api timed out after 30000ms'], + ['a thrown value that is not an error', { throws: 'lost' }, 'unknown', 'lost'], + ['GraphQL errors', output({ data: { resolveReviewThread: null }, errors: [{ message: 'one' }, { message: 'two' }] }, 1), 'failed', 'one; two'], + ['an HTTP refusal', output('', 1, 'gh: Validation Failed (HTTP 422)'), 'failed', 'gh: Validation Failed (HTTP 422)'], + ['a server error', output('', 1, 'gh: Server Error (HTTP 502)'), 'unknown', 'gh: Server Error (HTTP 502)'], + ['a failure printed on stdout', output('connection reset', 1), 'unknown', 'connection reset'], + ['a failure without output', output('', 1), 'unknown', 'gh exited with 1'], + ['an answer that is not JSON', output(''), 'unknown', 'unexpected GitHub response to resolveReviewThread'], + ['an empty payload', payload('resolveReviewThread', null), 'unknown', 'unexpected GitHub response to resolveReviewThread'], + ] as const)('reports a mutation ending in %s as %s', async (_label, answer, code, message) => { + const gh = fakeGh(checked(), answer) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'resolve', headSha: head, thread, resolved: true }, signal)) + .resolves.toEqual({ ok: false, code, message }) + }) + + it('treats a payload beside errors about its fields as applied', async () => { + const gh = fakeGh(checked(), output({ data: { resolveReviewThread: { thread: null } }, errors: [{ message: 'thread is not visible' }] })) + + await expect(provider(gh.run).writeReview(pull(7), { kind: 'resolve', headSha: head, thread, resolved: true }, signal)) + .resolves.toEqual({ ok: true }) + }) + + it('throws the abort reason when the signal aborts before a mutation is sent', async () => { + const controller = new AbortController() + const reason = new Error('closed') + const run: GhRunner = async () => { controller.abort(reason); return checked() } + + await expect(provider(run).writeReview(pull(7), { kind: 'resolve', headSha: head, thread, resolved: true }, controller.signal)).rejects.toBe(reason) + }) + + it('refuses an unconfigured host without starting gh and a head that is not a commit object name', async () => { + const gh = fakeGh(checked()) + const write: ForgeReviewWrite = { kind: 'resolve', headSha: head, thread, resolved: true } + + await expect(provider(gh.run).writeReview(pull(7, { ...demo, host: 'ghe.other.example' }), write, signal)) + .resolves.toEqual({ ok: false, code: 'failed', message: 'forge-github: host "ghe.other.example" is not configured' }) + await expect(provider(gh.run).writeReview(pull(7), { ...write, headSha: 'main' }, signal)).rejects.toThrow('forge-github: invalid head commit "main"') + expect(gh.calls).toEqual([]) + }) +}) diff --git a/packages/experimental/forge-github/tests/threads.spec.ts b/packages/experimental/forge-github/tests/threads.spec.ts index 42c49f389a..1e4fc80037 100644 --- a/packages/experimental/forge-github/tests/threads.spec.ts +++ b/packages/experimental/forge-github/tests/threads.spec.ts @@ -32,6 +32,7 @@ function page(nodes: readonly unknown[], next: string | null = null): Record = {}): Record { return { id, state: 'SUBMITTED', createdAt: '2026-11-05T08:00:00Z', url: `https://github.com/octo/demo/pull/7#discussion_${id}`, body: `Body of ${id}`, + viewerCanUpdate: false, viewerCanDelete: false, author: { __typename: 'User', login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4' }, ...overrides, } @@ -42,14 +43,20 @@ function threadNode(id: string, overrides: Record = {}, root: R return { id, isOutdated: false, isResolved: false, path: 'src/a.ts', diffSide: 'RIGHT', startDiffSide: null, line: 12, startLine: null, originalLine: 12, originalStartLine: null, subjectType: 'LINE', + viewerCanReply: true, viewerCanResolve: true, viewerCanUnresolve: false, root: { nodes: [{ diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c', commit: { oid: head }, originalCommit: { oid: head }, ...root }] }, comments: page([commentNode(`${id}-1`)]), ...overrides, } } -function firstPage(threads: Record, headRefOid = head, viewer: unknown = { login: 'octo' }): Record { - return { data: { viewer, repository: { pullRequest: { id: 'pull-7', headRefOid, reviewThreads: threads } } } } +function firstPage( + threads: Record, headRefOid = head, viewer: unknown = { login: 'octo' }, pull: Record = {}, + repository: Record = {}, +): Record { + return { data: { viewer, repository: { isArchived: false, viewerPermission: 'READ', ...repository, pullRequest: { + id: 'pull-7', headRefOid, locked: false, reviews: { nodes: [] }, reviewThreads: threads, ...pull, + } } } } } function nextPage(threads: Record, headRefOid = head): Record { @@ -84,16 +91,21 @@ describe('GitHub provider review threads', () => { headSha: head, viewer: 'octo', truncated: false, + pendingReview: null, + viewerCanReview: true, threads: [{ id: 't1', path: 'src/a.ts', side: 'R', subject: 'line', current: { commit: head, startLine: 12, endLine: 12 }, original: { commit: head, startLine: 12, endLine: 12 }, diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c', resolved: true, + viewerCanReply: true, + viewerCanResolve: false, comments: [{ id: 't1-1', body: 'Body of t1-1', createdAt: '2026-11-05T08:00:00Z', pending: false, url: 'https://github.com/octo/demo/pull/7#discussion_t1-1', author: { login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4', bot: false }, + viewerCanEdit: false, viewerCanDelete: false, }], }], }) @@ -161,6 +173,51 @@ describe('GitHub provider review threads', () => { expect(result).toMatchObject({ threads: { length: 1 } }) }) + it('maps the permissions of the viewer on threads and comments, resolving an unresolved thread and unresolving a resolved one', async () => { + const gh = review(firstPage(page([ + threadNode('open', { viewerCanReply: false, viewerCanResolve: true, viewerCanUnresolve: false, comments: page([ + commentNode('mine', { viewerCanUpdate: true, viewerCanDelete: true }), + ]) }), + threadNode('settled', { isResolved: true, viewerCanResolve: false, viewerCanUnresolve: true }), + ]))) + + const result = await provider(gh.run).readReview(pull(7), head, signal) + + expect(result).toMatchObject({ threads: [ + { id: 'open', viewerCanReply: false, viewerCanResolve: true, comments: [{ id: 'mine', viewerCanEdit: true, viewerCanDelete: true }] }, + { id: 'settled', viewerCanReply: true, viewerCanResolve: true }, + ] }) + }) + + it('reports the pending review the viewer authored with its commit and comment count', async () => { + const reviews = { nodes: [{ id: 'review-1', viewerDidAuthor: true, commit: { oid: older }, comments: { totalCount: 2 } }] } + const gh = review(firstPage(page([]), head, { login: 'octo' }, { reviews })) + + await expect(provider(gh.run).readReview(pull(7), head, signal)) + .resolves.toMatchObject({ pendingReview: { id: 'review-1', commit: older, comments: 2 } }) + }) + + it.each([ + ['another user authored', { id: 'review-2', viewerDidAuthor: false, commit: { oid: head }, comments: { totalCount: 1 } }], + ['names no commit', { id: 'review-3', viewerDidAuthor: true, commit: null, comments: { totalCount: 1 } }], + ] as const)('reports no pending review for one %s', async (_label, node) => { + const gh = review(firstPage(page([]), head, { login: 'octo' }, { reviews: { nodes: [node] } })) + + await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toMatchObject({ pendingReview: null }) + }) + + it.each([ + ['an archived repository', {}, { isArchived: true, viewerPermission: 'ADMIN' }, false], + ['a locked conversation without write access', { locked: true }, { viewerPermission: 'TRIAGE' }, false], + ['a locked conversation without a known permission', { locked: true }, { viewerPermission: null }, false], + ['a locked conversation with write access', { locked: true }, { viewerPermission: 'WRITE' }, true], + ['an open conversation with read access', {}, { viewerPermission: 'READ' }, true], + ] as const)('reports whether the viewer can review in %s', async (_label, pullFields, repository, expected) => { + const gh = review(firstPage(page([]), head, { login: 'octo' }, pullFields, repository)) + + await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toMatchObject({ viewerCanReview: expected }) + }) + it('reads the following thread and comment pages, asking only for the threads left under maxReviewThreads', async () => { const gh = review( firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })], 'threads-1')), @@ -198,7 +255,7 @@ describe('GitHub provider review threads', () => { const byComments = review(first, threads, comments) await expect(provider(byComments.run, { maxOutputBytes: size + 10 }).readReview(pull(7), head, signal)) - .resolves.toEqual({ headSha: head, viewer: 'octo', threads: [], truncated: true }) + .resolves.toEqual({ headSha: head, viewer: 'octo', threads: [], truncated: true, pendingReview: null, viewerCanReview: true }) const commentBytes = JSON.stringify(comments['comments-1']).length const byThreads = review(first, threads, comments) diff --git a/packages/experimental/forge-github/tsconfig.json b/packages/experimental/forge-github/tsconfig.json index fea1cc6ec1..ff2c3474bc 100644 --- a/packages/experimental/forge-github/tsconfig.json +++ b/packages/experimental/forge-github/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../util/brand" + }, + { + "path": "../../util/values" } ] } diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index 88068c3aee..1373810e80 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -8,7 +8,7 @@ import { nextPollDelay } from './schedule.ts' import type { ChangeRequestKey, ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, Config, ForgeCommitList, ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, - MergeRequest, + ForgeReviewWrite, ForgeReviewWriteResult, MergeRequest, } from './types.ts' export { changeRequestKey } from './keys.ts' @@ -253,7 +253,7 @@ export class ForgeService extends Service { async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise { const result = await this.invoke(this.registration(ref.repository.forge), signal, (provider, operation) => provider.diff(ref, headSha, operation)) - if (typeof result !== 'string') this.staleRead(ref, headSha, result) + if (typeof result !== 'string') this.staleRead(ref, headSha, result.code) return result } @@ -270,7 +270,26 @@ export class ForgeService extends Service { const result = await this.invoke(this.registration(ref.repository.forge), signal, (provider, operation) => provider.readReview?.(ref, headSha, operation) ?? Promise.resolve({ code: 'unsupported', message: 'This provider does not support review reads' } as const)) - if ('code' in result) this.staleRead(ref, headSha, result) + if ('code' in result) this.staleRead(ref, headSha, result.code) + return result + } + + /** + * Write to the review threads or the pending review of a change request while its head is still `write.headSha`, then + * poll the change request at once when it is watched. A `stale` result polls it as a stale read does. + * @param ref - change request served by a registered provider. + * @param write - the write and the head commit the user saw. + * @param signal - cancellation of the write. + * @returns whether the host took the write; `unsupported` when the provider cannot write reviews, and `unknown` when the + * outcome must be checked on the host before the write is tried again. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ + async writeReview(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise { + const result = await this.invoke(this.registration(ref.repository.forge), signal, + (provider, operation) => provider.writeReview?.(ref, write, operation) + ?? Promise.resolve({ ok: false, code: 'unsupported', message: 'This provider does not support review writes' } as const)) + if (!result.ok && result.code === 'stale') this.staleRead(ref, write.headSha, result.code) + else this.refresh(ref) return result } @@ -398,10 +417,10 @@ export class ForgeService extends Service { return this.watches.get(changeRequestKey(ref))?.latest } - /** Poll a watched change request at once after the first `stale` read at `headSha`; later ones wait for the regular poll. */ - private staleRead(ref: ChangeRequestRef, headSha: string, failure: ForgeFailure): void { + /** Poll a watched change request at once after the first `stale` read or write at `headSha`; later ones wait for the regular poll. */ + private staleRead(ref: ChangeRequestRef, headSha: string, code: ForgeFailure['code']): void { const watch = this.watches.get(changeRequestKey(ref)) - if (failure.code !== 'stale' || watch === undefined || watch.staleHead === headSha) return + if (code !== 'stale' || watch === undefined || watch.staleHead === headSha) return watch.staleHead = headSha this.refresh(ref) } diff --git a/packages/experimental/forge/src/types.ts b/packages/experimental/forge/src/types.ts index 71bd7270e0..6ef6526059 100644 --- a/packages/experimental/forge/src/types.ts +++ b/packages/experimental/forge/src/types.ts @@ -160,7 +160,7 @@ export type ForgeReviewRequest = { readonly headSha: string } & ( | { readonly kind: 'comment'; readonly comment: ForgeLineComment } - | { readonly kind: 'review'; readonly event: 'comment' | 'approve' | 'request-changes'; readonly body: string; readonly comments: readonly ForgeLineComment[] } + | { readonly kind: 'review'; readonly event: ForgeReviewEvent; readonly body: string; readonly comments: readonly ForgeLineComment[] } ) /** Opaque host identity of one review thread. */ @@ -169,6 +169,9 @@ export type ForgeThreadId = Branded<'ForgeThreadId'> /** Opaque host identity of one review comment. */ export type ForgeCommentId = Branded<'ForgeCommentId'> +/** Opaque host identity of one review. */ +export type ForgeReviewId = Branded<'ForgeReviewId'> + /** Consecutive lines of one file at one commit. */ export interface ForgeAnchor { /** Full object name of the commit whose file the lines number. */ @@ -203,6 +206,10 @@ export interface ForgeReviewComment { readonly pending: boolean /** Web page of the comment, or null when the host reports none. */ readonly url: string | null + /** Whether the signed-in user can replace the comment's text through `writeReview`; false for a provider without it. */ + readonly viewerCanEdit: boolean + /** Whether the signed-in user can delete the comment through `writeReview`; false for a provider without it. */ + readonly viewerCanDelete: boolean } /** @@ -227,10 +234,27 @@ export interface ForgeReviewThread { readonly diffHunk: string | null /** Whether the thread is marked resolved. */ readonly resolved: boolean + /** Whether the signed-in user can reply to the thread through `writeReview`; false for a provider without it. */ + readonly viewerCanReply: boolean + /** + * Whether the signed-in user can mark the thread resolved, or unresolved when `resolved`, through `writeReview`; false + * for a provider without it. + */ + readonly viewerCanResolve: boolean /** Comments oldest first; the first starts the thread. */ readonly comments: readonly ForgeReviewComment[] } +/** The signed-in user's review of a change request that is not submitted yet, which only that user sees. */ +export interface ForgePendingReview { + /** Host identity of the review. */ + readonly id: ForgeReviewId + /** Full object name of the commit the review addresses. */ + readonly commit: string + /** Comments the review holds. */ + readonly comments: number +} + /** Review threads of a change request read at one head commit. */ export interface ForgeReviewData { /** Head commit the threads were read at; `current` positions number its files. */ @@ -241,8 +265,44 @@ export interface ForgeReviewData { readonly threads: readonly ForgeReviewThread[] /** Whether the change request has more threads than `threads` holds. */ readonly truncated: boolean + /** The signed-in user's pending review, or null without one. */ + readonly pendingReview: ForgePendingReview | null + /** + * Whether the signed-in user can add comments to a pending review and submit or discard it through `writeReview`; + * false for a provider without it. + */ + readonly viewerCanReview: boolean } +/** Decision a submitted review records. */ +export type ForgeReviewEvent = 'comment' | 'approve' | 'request-changes' + +/** + * One write to the review threads and the pending review of a change request. `pending` replies and `add-pending` + * comments join the signed-in user's pending review, which is started when the user has none. A host that adds every + * reply to an existing pending review, as GitHub does, refuses a reply with `pending: false` while one exists. + */ +export type ForgeReviewWrite = { + /** Head commit the user saw; providers check it before writing and refuse with `stale` once the head is another commit. */ + readonly headSha: string +} & ( + | { readonly kind: 'reply'; readonly thread: ForgeThreadId; readonly body: string; readonly pending: boolean } + | { readonly kind: 'resolve'; readonly thread: ForgeThreadId; readonly resolved: boolean } + | { readonly kind: 'edit-comment'; readonly comment: ForgeCommentId; readonly body: string } + /** Deleting the only comment of a thread deletes the thread. */ + | { readonly kind: 'delete-comment'; readonly comment: ForgeCommentId } + /** A comment on lines of `headSha`. */ + | { readonly kind: 'add-pending'; readonly comment: ForgeLineComment } + | { readonly kind: 'submit-pending'; readonly review: ForgeReviewId; readonly event: ForgeReviewEvent; readonly body: string } + /** Delete the pending review with every comment it holds. */ + | { readonly kind: 'discard-pending'; readonly review: ForgeReviewId } +) + +/** Result of a review write; an `unknown` outcome must be checked on the host before the write is tried again. */ +export type ForgeReviewWriteResult = + | { readonly ok: true } + | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } + /** Result of a review write; unknown outcomes must be checked on the host before another submission. */ export type ForgeReviewResult = | { readonly ok: true; readonly url: string } @@ -306,6 +366,12 @@ export interface ForgeProvider { * Omission declares that this provider cannot read reviews. */ readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + /** + * Write to the review threads or the pending review of a change request while its head is `write.headSha`; once the + * head is another commit, the write resolves to `stale` without writing. A write is never retried. Omission declares + * that this provider cannot write reviews, and its `readReview` then reports no viewer permission. + */ + writeReview?(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ diff --git a/packages/experimental/forge/tests/registry.spec.ts b/packages/experimental/forge/tests/registry.spec.ts index a87103be4e..b9d24f6338 100644 --- a/packages/experimental/forge/tests/registry.spec.ts +++ b/packages/experimental/forge/tests/registry.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import ForgeService, { changeRequestKey } from '../src/index.ts' -import type { ForgeReviewData } from '../src/types.ts' +import type { ForgeReviewData, ForgeReviewWrite, ForgeThreadId } from '../src/types.ts' import { demo, FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' let ctx: Context | undefined @@ -134,13 +134,30 @@ describe('forge registry', () => { await expect(ctx.forge.readReview(ref(3), 'a'.repeat(40), signal)) .resolves.toEqual({ code: 'unsupported', message: 'This provider does not support review reads' }) - const review: ForgeReviewData = { headSha: 'a'.repeat(40), viewer: 'octo', threads: [], truncated: false } + const review: ForgeReviewData = { + headSha: 'a'.repeat(40), viewer: 'octo', threads: [], truncated: false, pendingReview: null, viewerCanReview: false, + } const readReview = vi.fn(async () => review) Object.assign(provider, { readReview }) await expect(ctx.forge.readReview(ref(3), 'a'.repeat(40), signal)).resolves.toBe(review) expect(readReview).toHaveBeenCalledWith(ref(3), 'a'.repeat(40), expect.any(AbortSignal)) }) + it('delegates review writes to the provider of their forge and reports a provider without them as unsupported', async () => { + ctx = await forgeContext() + const provider = new FakeProvider() + await provide(ctx, provider) + const signal = new AbortController().signal + const write: ForgeReviewWrite = { kind: 'resolve', headSha: 'a'.repeat(40), thread: 'thread-1' as ForgeThreadId, resolved: true } + + await expect(ctx.forge.writeReview(ref(3), write, signal)) + .resolves.toEqual({ ok: false, code: 'unsupported', message: 'This provider does not support review writes' }) + const writeReview = vi.fn(async () => ({ ok: true as const })) + Object.assign(provider, { writeReview }) + await expect(ctx.forge.writeReview(ref(3), write, signal)).resolves.toEqual({ ok: true }) + expect(writeReview).toHaveBeenCalledWith(ref(3), write, expect.any(AbortSignal)) + }) + it('delegates change request actions to the provider of their forge and returns its outcome', async () => { ctx = await forgeContext() const provider = new FakeProvider() diff --git a/packages/experimental/forge/tests/watch.spec.ts b/packages/experimental/forge/tests/watch.spec.ts index 5903fb42fa..43dc792c07 100644 --- a/packages/experimental/forge/tests/watch.spec.ts +++ b/packages/experimental/forge/tests/watch.spec.ts @@ -1,7 +1,10 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ForgeService, { changeRequestKey } from '../src/index.ts' -import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, ForgeFailure, ForgeRepository, ForgeReviewData } from '../src/types.ts' +import type { + ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, ForgeFailure, ForgeRepository, ForgeReviewData, + ForgeReviewWrite, ForgeReviewWriteResult, ForgeThreadId, +} from '../src/types.ts' import { FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' const config = { activeIntervalMs: 1_000, idleIntervalMs: 5_000, maxBackoffMs: 6_000, fetchTimeoutMs: 500 } @@ -289,6 +292,33 @@ describe('forge watch', () => { expect(provider.fetches).toHaveLength(2) }) + it('polls at once after every review write, and after a stale one only once per head', async () => { + const provider = new FakeProvider() + provider.results.set(key, snapshot(ref(1))) + const writeReview = vi.fn(async (): Promise => ({ ok: true })) + Object.assign(provider, { writeReview }) + const context = await setup(provider) + collect(context) + await vi.advanceTimersByTimeAsync(0) + const signal = new AbortController().signal + const write: ForgeReviewWrite = { kind: 'reply', headSha: 'a'.repeat(40), thread: 'thread-1' as ForgeThreadId, body: 'Done', pending: false } + + await expect(context.forge.writeReview(ref(1), write, signal)).resolves.toEqual({ ok: true }) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(2) + writeReview.mockResolvedValue({ ok: false, code: 'unknown', message: 'connection reset' }) + await context.forge.writeReview(ref(1), write, signal) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(3) + writeReview.mockResolvedValue({ ok: false, code: 'stale', message: 'the head moved' }) + await expect(context.forge.writeReview(ref(1), write, signal)).resolves.toEqual({ ok: false, code: 'stale', message: 'the head moved' }) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(4) + await context.forge.writeReview(ref(1), write, signal) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toHaveLength(4) + }) + it('coalesces refreshes during a fetch into one immediate subsequent fetch', async () => { const provider = new FakeProvider() const release = held(provider, target => snapshot(target)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a2b3cbd92..f6b6836d6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10172,6 +10172,9 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery From b1aa940b0fa92b870ff91539cea9ba23480b53f5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 9 Nov 2026 11:21:15 +0800 Subject: [PATCH 66/81] feat(forge-panel): reply to, resolve, and edit review threads and submit the pending review Thread cards gain a reply field, Resolve and Unresolve, and edit and delete for the viewer's own comments. Start review and Add to review add pull request comments to the GitHub pending review, which Submit review submits or discards; while one exists, replies join it and no single comment is offered, as GitHub refuses one. On the viewer's own pull request the dialog offers only Comment. Comment drafts an earlier panel kept in the browser stay editable, can move into the pending review, and block submission while one exists. --- .../client-ui-forge/src/client/ChangesTab.tsx | 113 +++++++- .../src/client/ReviewComposer.tsx | 53 ++-- .../src/client/ReviewThread.tsx | 31 ++- .../src/client/ReviewThreads.module.css | 78 ++++++ .../src/client/ReviewThreads.tsx | 177 +++++++++++- .../src/client/SubmitReview.module.css | 3 + .../src/client/SubmitReview.tsx | 51 +++- .../client-ui-forge/src/client/locales.ts | 184 +++++++++++++ .../client-ui-forge/src/client/mount.ts | 3 +- .../src/client/review-queue.ts | 15 +- .../src/client/thread-state.ts | 6 +- .../tests/changes-tab.client.spec.tsx | 259 ++++++++++++++++-- .../tests/mount.client.spec.ts | 15 +- .../tests/review-thread-cards.client.spec.tsx | 174 +++++++++++- .../tests/review.client.spec.tsx | 190 ++++++++++--- .../tests/thread-fixtures.client.ts | 9 +- 16 files changed, 1214 insertions(+), 147 deletions(-) diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 0747a901bc..91ef3242e7 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -22,13 +22,14 @@ import { import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { SidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { assertNever } from '@deepseek-ai/dsh-util-values' import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { ForgeBaseBranch, ForgeBaseBranches, ForgeChangeRequestDiff, ForgeCommitRange, ForgeDiffFile, ForgeLocalCommits, ForgeLocalDiff, ForgeLocalScope, ForgePanelChangeRequest, ForgePanelState, } from '../types.ts' import type { - ForgeCommitList, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, ForgeReviewThread, + ForgeCommitList, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, ForgeReviewThread, ForgeReviewWrite, ForgeReviewWriteResult, } from '@deepseek-ai/dsh-experimental-forge/types' import { sameReviewSource, type ReviewQueue } from './review-queue.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' @@ -49,7 +50,7 @@ import { drawnLines, matchesHunks, textLines } from './diff-rows.ts' import { ChangeRequestGlyph } from './glyphs.tsx' import type { ForgePanelKey, NS } from './locales.ts' import { RefreshGlyph } from './RefreshGlyph.tsx' -import type { FileThreads, ThreadActions, ThreadFocus } from './ReviewThreads.tsx' +import type { FileThreads, ThreadActions, ThreadFocus, ThreadWrite } from './ReviewThreads.tsx' import { inFileOrder, type ThreadFilter } from './review-threads.ts' import { usePlacedThreads, useThreadRead, type PlacedThread } from './thread-state.ts' import { ThreadList } from './ThreadList.tsx' @@ -94,6 +95,8 @@ export interface ChangesTabInjected { readonly changeRequestText: (url: string, commit: string, path: string) => Promise> /** Read the review threads of a change request at the head of its latest status; `signal` aborts the read. */ readonly changeRequestReview: (url: string, headSha: string, signal: AbortSignal) => Promise> + /** Write to the review threads or the pending review of a change request; never retries a write. */ + readonly changeRequestReviewWrite: (url: string, write: ForgeReviewWrite) => Promise> /** Quote a whole review thread of the change request at `url` in the main input without submitting it. */ readonly addThreadToInput: (thread: ForgeReviewThread, url: string, range: string | null) => boolean /** Open a URL outside the app. */ @@ -433,7 +436,7 @@ function OrphanReviews({ path, actions, t }: PropsLocale & { */ export function ChangesTab({ useTabInfo, sessionId, useStore, actions, usePanel, reconnect, localDiff, diffBases, localText, localCommits, changeRequestDiff, - changeRequestText, changeRequestCommits, changeRequestReview, viewedFiles, + changeRequestText, changeRequestCommits, changeRequestReview, changeRequestReviewWrite, viewedFiles, openLink, copyPath, createChangeRequest, addReviewToInput, addThreadToInput, reviewQueue, submitReview, report, t, }: ChangesTabProps): ReactNode { const { tab } = useTabInfo() @@ -482,6 +485,40 @@ export function ChangesTab({ setReviewBusy(false) } } + // Every write that may have changed the threads reads them again; writes run one at a time, so a write that starts a + // pending review settles before the next write looks for one. + const [reviewRevision, setReviewRevision] = useState(0) + const writing = useRef>(Promise.resolve()) + const writeReview = (url: string, write: ForgeReviewWrite): Promise => { + const run = async (): Promise => { + const response = await changeRequestReviewWrite(url, write) + if (!response.ok) { + if (response.error.code === 'forgePanel/not-bound') return t('thread.writeFailed', { message: response.error.message }) + setReviewRevision(value => value + 1) + return t('thread.writeUnknown') + } + const result = response.value + if (result.ok) { + setReviewRevision(value => value + 1) + return undefined + } + switch (result.code) { + case 'unsupported': return t('thread.writeUnsupported') + case 'failed': + setReviewRevision(value => value + 1) + return t('thread.writeFailed', { message: result.message }) + case 'stale': return t('thread.writeStale') + case 'unknown': + setReviewRevision(value => value + 1) + return t('thread.writeUnknown') + /* v8 ignore next -- closed ForgeReviewWriteResult code union exhaustiveness guard */ + default: return assertNever(result.code, 'ForgeReviewWriteResult.code') + } + } + const next = writing.current.then(run, run) + writing.current = next + return next + } const choices = useStore(store => store.byTab[tab.id]) const [wanted, setWanted] = useState(null) const [treeState, setTreeState] = useState({ query: '', collapsed: {}, selected: null }) @@ -575,7 +612,7 @@ export function ChangesTab({ const prHead = entry?.snapshot?.headSha ?? null const prAuthor = entry?.snapshot?.author ?? null const threadRead = useThreadRead({ visible: tab.visible, url: source.kind === 'change' ? source.url : null, head: prHead, - updatedAt: entry?.snapshot?.updatedAt ?? null, statusAt, attempt, read: changeRequestReview }) + updatedAt: entry?.snapshot?.updatedAt ?? null, statusAt, attempt, revision: reviewRevision, read: changeRequestReview }) const review = data?.kind === 'change' && threadRead.review !== null && threadRead.review.headSha === prHead && (data.commits !== null || data.headSha === prHead) ? threadRead.review : null const placed = usePlacedThreads(review, data?.kind === 'change' ? { head: prHead ?? '', end: data.headSha, commits: data.commits } : null) @@ -689,16 +726,61 @@ export function ChangesTab({ return list } - // A review addresses the head commit, so it is submitted from the view of the whole change request. + // A review addresses the head commit, so it is submitted from the view of the whole change request. A pending review on + // the code host is submitted or discarded there; without one, the summary and any comment drafts kept in the browser + // are published as one review. + const pendingReview = review?.pendingReview ?? null + // Logins compare without case, as GitHub's do. + const ownChangeRequest = review?.viewer != null && prAuthor !== null && review.viewer.toLowerCase() === prAuthor.toLowerCase() + const finishWrite = async (send: () => Promise, done: ForgePanelKey): Promise => { + if (reviewRunning.current) return + reviewRunning.current = true + setReviewBusy(true) + setReviewError(null) + try { + const failure = await send() + if (failure !== undefined) { + setReviewError(failure) + return + } + report(done) + setReviewOpen(false) + } finally { + reviewRunning.current = false + setReviewBusy(false) + } + } const reviewForm = tab.visible && data !== null && reviewOpen && data.kind === 'change' && data.commits === null && { setReviewOpen(false) }} + url={data.url} headSha={data.headSha} pending={pendingReview} own={ownChangeRequest} busy={reviewBusy} error={reviewError} + close={() => { setReviewOpen(false) }} check={() => { openLink(data.url) }} reconcile={() => { queue.finish(false); setReviewError(null) }} submit={() => { const latest = queue.getSnapshot() - void publishReview(data.url, { kind: 'review', headSha: data.headSha, event: latest.event, - body: latest.body, comments: latest.comments.map(item => item.comment) }, url => queue.finish(true, url)) + if (pendingReview === null) { + void publishReview(data.url, { kind: 'review', headSha: data.headSha, event: latest.event, + body: latest.body, comments: latest.comments.map(item => item.comment) }, url => queue.finish(true, url)) + return + } + void finishWrite(async () => { + const failure = await writeReview(data.url, { kind: 'submit-pending', headSha: data.headSha, review: pendingReview.id, + event: latest.event, body: latest.body }) + if (failure === undefined && !queue.finish(true)) report('review.saveFailed') + return failure + }, 'review.submitted') + }} + discard={pendingReview === null ? undefined : () => { + void finishWrite(() => writeReview(data.url, { kind: 'discard-pending', headSha: data.headSha, review: pendingReview.id }), 'review.discarded') }} t={t} /> + /** Add a comment to the pending review on the code host at the head the comment was selected on. */ + const addPending = (selection: ReviewSelection): Promise => { + if (selection.source.kind !== 'change') return Promise.resolve(t('review.stale')) + const { path, side, startLine, endLine, body } = selection.comment + return writeReview(selection.source.url, { kind: 'add-pending', headSha: selection.source.headSha, comment: { path, side, startLine, endLine, body } }) + } + // Only the whole change request takes new comments for its pending review, once the threads say the user may review. + const pendingComments = data?.kind === 'change' && data.commits === null && review?.viewerCanReview === true ? { queue: addPending } : {} + const recordActions: ReviewActions = { source: shownSource ?? (source.kind === 'local' ? { kind: 'local', scope, base: null, baseRef: baseRefOf(localScope), commits: commitsOf(localScope) } @@ -718,6 +800,11 @@ export function ChangesTab({ readText: (commit, path) => changeRequestText(data.url, commit, path), inputFailed: () => { report('thread.inputFailed') }, focused: focus, + writes: { + write: (write: ThreadWrite) => writeReview(data.url, { ...write, headSha: review.headSha }), + reviewStarted: review.pendingReview !== null, + canReview: review.viewerCanReview, + }, } const threadsOf = (path: string): FileThreads | undefined => { const group = placed.files.get(path) @@ -869,11 +956,12 @@ export function ChangesTab({ file={file} review={{ source: reviewSourceOf(data), addToInput: addReviewToInput, - records: queue, openLink, reviewStarted: reviewState.comments.length > 0, + records: queue, openLink, reviewStarted: pendingReview !== null, onQuoted: (selection) => { if (!queue.recordAgent(selection)) report('review.saveFailed') }, publishingBlocked: reviewState.uncertain !== null, - ...data.kind === 'local' || data.commits !== null ? {} : { - queue: selection => queue.add(selection), + ...pendingComments, + // GitHub refuses a comment published on its own while the user has a pending review. + ...data.kind === 'local' || data.commits !== null || pendingReview !== null ? {} : { publish: selection => selection.source.kind === 'change' ? publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment }, url => queue.published(selection, url)) @@ -939,7 +1027,8 @@ export function ChangesTab({ ? { actions.based(tab.id, next) }} commitMenu={commitMenu} createChangeRequest={createChangeRequest} t={t} /> - : { setReviewOpen(true) }} t={t} />} {!reviewOpen && reviewState.error !== null && {t(reviewState.error === 'load' ? 'review.loadFailed' : 'review.saveFailed')}} {!reviewOpen && reviewState.uncertain !== null && !reviewBusy && source.kind === 'change' &&
diff --git a/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx b/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx index 94407dfee8..82e482c35f 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx @@ -15,7 +15,7 @@ export interface ReviewActions { readonly source: ReviewSource /** Browser records shared by the changes tab. */ readonly records?: ReviewQueue - /** Whether another comment already started a pending review. */ + /** Whether the signed-in user has a pending review on the code host that the next comment joins. */ readonly reviewStarted?: boolean /** Open the confirmed publication URL. */ readonly openLink?: (url: string) => void @@ -27,26 +27,32 @@ export interface ReviewActions { readonly onQuoted?: (selection: ReviewSelection) => void /** Publish one comment; an error string preserves the editor. */ readonly publish?: (selection: ReviewSelection) => Promise - /** Keep one comment for the final review; false preserves the editor. */ - readonly queue?: (selection: ReviewSelection) => boolean + /** Add one comment to the pending review on the code host, starting one when needed; an error string preserves the editor. */ + readonly queue?: (selection: ReviewSelection) => Promise } -function GitHubAction({ disabled, busy, started, queue, publish, t }: PropsLocale & { +/** + * Render a primary action with an optional second action in its menu. + * @param props - the main action, the optional menu action, the menu's name, and availability. + * @returns the split button. + */ +export function SplitAction({ disabled, label, run, menuLabel, menu, other }: { readonly disabled: boolean - readonly busy: boolean - readonly started: boolean - readonly queue: () => void - readonly publish: (() => void) | undefined + readonly label: string + readonly run: () => void + /** Accessible name of the menu button. */ + readonly menuLabel: string + /** Label of the action in the menu, or undefined without one. */ + readonly other: string | undefined + readonly menu: () => void }): ReactNode { const [open, setOpen] = useState(false) return
- - {publish !== undefined && { setOpen(false) }} + + {other !== undefined && { setOpen(false) }} anchor={}> - { setOpen(false); publish() }}>{t('review.publish')} + aria-label={menuLabel} onClick={() => { setOpen(value => !value) }}>}> + { setOpen(false); menu() }}>{other} }
} @@ -73,13 +79,14 @@ export function ReviewComposer({ selection, actions, close, visible = true, spli useEffect(() => { input.current?.focus() }, []) const comment = (): ReviewSelection => ({ ...selection, comment: { ...selection.comment, body: body.current } }) const range = reviewRange(selection.comment) - const publish = async (): Promise => { - if (running.current || actions.publish === undefined) return + /** Send the comment to the code host, closing the form once it is taken and keeping its text otherwise. */ + const send = async (destination: ((selection: ReviewSelection) => Promise) | undefined): Promise => { + if (running.current || destination === undefined) return running.current = true setBusy(true) setError(null) try { - const failure = await actions.publish(comment()) + const failure = await destination(comment()) if (failure === undefined) close() else setError(failure) } catch (_error) { @@ -108,14 +115,12 @@ export function ReviewComposer({ selection, actions, close, visible = true, spli if (actions.addToInput(selected)) { actions.onQuoted?.(selected); close() } else setError(t('review.inputFailed')) }}>{t('review.addToInput')} - {visible && actions.queue !== undefined && { - if (actions.queue?.(comment()) === true) close() - else setError(t('review.saveFailed')) - }} publish={actions.publish === undefined ? undefined : () => { void publish() }} t={t} />} + {visible && actions.queue !== undefined && { void send(actions.queue) }} menuLabel={t('review.githubActions')} + other={actions.publish === undefined ? undefined : t('review.publish')} menu={() => { void send(actions.publish) }} />} {actions.queue === undefined && actions.publish !== undefined && } + disabled={busy || actions.publishingBlocked === true || empty} onClick={() => { void send(actions.publish) }}>{t('review.publish')}}
diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx b/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx index f626c2dc47..7abb3a7f67 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx @@ -1,4 +1,4 @@ -/** Inline comment records and editing of a pending review at its captured code location. */ +/** Inline comment records and the editing of comment drafts kept in the browser at their captured code location. */ import { useCallback, useState, useSyncExternalStore, type ReactNode } from 'react' import { Button, Tag } from '@deepseek-ai/dsh-client-ui-primitives' @@ -24,7 +24,8 @@ export function useFileReview(actions: ReviewActions | undefined, path: string): } /** - * Render a saved comment at its line, with pending edits kept in the browser queue. + * Render a saved comment at its line. A comment draft kept in the browser can be edited, deleted, or moved into the + * pending review on the code host. * @param props - record, edit availability, source actions, and localized copy. * @returns one compact inline comment card. */ @@ -38,11 +39,27 @@ export function ReviewThread({ thread, actions, locked, outdated, split, t }: Pr const [editing, setEditing] = useState(false) const [body, setBody] = useState(thread.selection.comment.body) const [error, setError] = useState(null) + const [moving, setMoving] = useState(false) const comment = thread.selection.comment const quoted = editing && thread.kind === 'pending' ? { ...thread.selection, comment: { ...comment, body } } : thread.selection const range = reviewRange(comment) - const status = thread.kind === 'pending' ? 'review.pending' : thread.kind === 'published' ? 'review.published' : 'review.quoted' + const status = thread.kind === 'pending' ? 'review.browserDraft' : thread.kind === 'published' ? 'review.published' : 'review.quoted' const save = (): boolean => actions.records?.editThread(thread.key, body) === true + const queue = actions.queue + /** Add the draft to the pending review on the code host, then delete it from the browser. */ + const move = async (add: NonNullable): Promise => { + setMoving(true) + setError(null) + try { + const failure = await add(thread.selection) + if (failure !== undefined) setError(failure) + else if (actions.records?.editThread(thread.key, null) !== true) setError(t('review.saveFailed')) + } catch (_error) { + setError(t('review.unknown')) + } finally { + setMoving(false) + } + } return
@@ -65,13 +82,15 @@ export function ReviewThread({ thread, actions, locked, outdated, split, t }: Pr }}>{t('review.addToInput')} {thread.kind === 'pending' && <> {editing - ? - : } - } + + {queue !== undefined && !editing && !outdated && } } {thread.kind === 'published' && actions.openLink !== undefined && }
diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css index b84b5a8428..046ba7baad 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css @@ -151,6 +151,84 @@ margin: 10px -8px -4px; } +/* A reply or a comment edit uses the inline review form's field; its actions sit under it like the form's. */ +.form { margin-top: 10px; } + +.formActions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +/* The collapsed reply field, a field-shaped button that opens the reply form. */ +.replyPrompt { + display: block; + box-sizing: border-box; + width: 100%; + min-height: 32px; + margin-top: 10px; + padding: 6px 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-3); + color: var(--dsw-alias-label-dimmed); + font: inherit; + text-align: start; + cursor: text; +} + +.replyPrompt:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 2px; +} + +.menuAnchor { display: inline-flex; flex: none; } + +/* The comment menu's icon button keeps the 20px row height of the header it ends. */ +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-xs); + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.icon[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } + +.icon:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 1px; +} + +@media (hover: hover) { + .icon:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } +} + +/* Confirming a deletion replaces nothing: the comment stays readable above the question. */ +.confirm { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.confirmText { flex: 1 1 auto; min-width: 0; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; } + +.confirmError, +.actionError { flex-basis: 100%; margin-top: 0; } + +.actionError { padding-inline: 8px; } + /* The end of the recorded hunk, drawn like the diff rows it came from. */ .excerpt, .original { diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx index 02cf0c8fae..feedfe071b 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx @@ -1,23 +1,44 @@ /** - * Read-only review threads of a change request inside the changes view: a thread card at its lines or at the top of - * its file, a resolved thread folded into one row, and the outdated threads of a file in a section after its lines. + * Review threads of a change request inside the changes view: a thread card at its lines or at the top of its file, a + * resolved thread folded into one row, and the outdated threads of a file in a section after its lines. A card offers + * the writes the signed-in user may make: a reply, published at once or added to the pending review, resolving or + * unresolving the thread, and editing or deleting the user's own comments. */ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import { - Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconRightUpOutlineRegular, MarkdownText, Tag, type MarkdownLabels, + Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconEllipsisOutlineRegular, IconRightUpOutlineRegular, MarkdownText, + Menu, Tag, Tooltip, type MarkdownLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { ForgeReviewComment, ForgeReviewThread, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' +import type { ForgeReviewComment, ForgeReviewThread, ForgeReviewWrite, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { formatAgo } from './format.ts' import { anchorRange, hunkExcerpt, type ThreadPlace } from './review-threads.ts' +import { SplitAction } from './ReviewComposer.tsx' import type { NS } from './locales.ts' import css from './ReviewThreads.module.css' +import composerCss from './ReviewComposer.module.css' type Translate = PropsLocale['t'] +/** Each member of a union without its `headSha`. */ +type WithoutHead = T extends unknown ? Omit : never + +/** A write to one thread or its comments, without the head commit the changes view adds. */ +export type ThreadWrite = WithoutHead> + +/** Writes the signed-in user can make to the threads of one changes view. */ +export interface ThreadWrites { + /** Send a write; resolves to the message to show, or undefined once the code host took it. */ + readonly write: (write: ThreadWrite) => Promise + /** Whether the user has a pending review, which every reply then joins. */ + readonly reviewStarted: boolean + /** Whether the user can add a reply to a pending review, starting one when needed. */ + readonly canReview: boolean +} + /** Reads, actions, and identities every thread card of one changes view shares. */ export interface ThreadActions { /** Login of the signed-in user, or null when the host names none. */ @@ -34,6 +55,8 @@ export interface ThreadActions { readonly inputFailed: () => void /** The latest jump to a thread, which unfolds that thread and its section until the reader folds them again; null before any. */ readonly focused: ThreadFocus | null + /** Writes the user can make, or null when the code host takes none from this view. */ + readonly writes: ThreadWrites | null } /** One jump to a thread; each jump has its own revision, so a jump to the same thread unfolds it again. */ @@ -97,14 +120,77 @@ export function Avatar({ author, size = 20 }: { readonly author: ForgeReviewComm } -function Comment({ comment, actions, labels, now, t }: { +/** Run one write at a time for a card section, keeping its error until the next attempt. */ +function useWriting(t: Translate): { + readonly busy: boolean + readonly error: string | null + readonly run: (send: () => Promise) => Promise +} { + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const running = useRef(false) + const run = async (send: () => Promise): Promise => { + if (running.current) return false + running.current = true + setBusy(true) + setError(null) + try { + const failure = await send() + setError(failure ?? null) + return failure === undefined + } catch (_error) { + setError(t('thread.writeUnknown')) + return false + } finally { + running.current = false + setBusy(false) + } + } + return { busy, error, run } +} + +/** A plain-text field with Cancel and the actions that send it, for a reply or a comment edit. */ +function TextForm({ label, initial, busy, error, cancel, children }: { + readonly label: string + readonly initial: string + readonly busy: boolean + readonly error: string | null + readonly cancel: { readonly label: string; readonly run: () => void } + /** The sending actions, given the current text and whether it is blank. */ + readonly children: (text: string, blank: boolean) => ReactNode +}): ReactNode { + const [text, setText] = useState(initial) + return
+