From 9e724dcad777c08613c46f8803ac4caba88dd3d3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:48:46 +0800 Subject: [PATCH] test(windows): close final native coverage gaps --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 4 +- ...08-08-native-windows-pull-request-ci.zh.md | 4 +- .../bash/pwsh-local/tests/executor.spec.ts | 5 ++- .../tests/workspace-context.spec.ts | 7 ++-- .../tests/loader-composition.spec.ts | 8 +++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +- packages/lsp/lsp-local/tests/provider.spec.ts | 8 ++-- packages/lsp/tool-lsp/tests/render.spec.ts | 4 +- .../subagent-codex/tests/real-product.spec.ts | 30 +++++++++---- .../subagent-codex/tests/responses-fixture.ts | 42 +++++++++++++++++-- 11 files changed, 88 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 05a73c2053..2cdf3e8bfb 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 5aafb8b9c7557157135dfa6ecedffed42eb76183 -2026-08-08-native-windows-pull-request-ci.zh.md: 4c71f3eeda86fdc537c22629962b0eccbb994b65 +2026-08-08-native-windows-pull-request-ci.md: 8b657e3bffa7279560f3f87e5c35e7f462f2cac1 +2026-08-08-native-windows-pull-request-ci.zh.md: 6f23dd1df1ef31a8952425bdc6ed00a720d162dd diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 5aafb8b9c7..8b657e3bff 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -32,7 +32,9 @@ The final root-probe failure came from applying the extended-length namespace to The next complete coverage run reached six independent late failures rather than one shared cascade. React queue-action coverage now resolves its mocked request inside an awaited `act()` before observing the settled render. The unclosed-Markdown workload keeps all 6,400 candidates under an explicit three-second coverage budget, and the asynchronous workspace-projection warning test gives its outer case a twenty-second budget larger than its ten-second poll. Real Claude Code teardown uses asynchronous recursive removal with ten bounded retries after every managed handle reports exit, accommodating Windows' delayed handle release without weakening the quiescence assertions. -Two product boundaries required foundation repairs. Include's debounced config persistence previously launched an unobserved promise from a timer; a transient Windows `EPERM` while replacing `cordis.yml` could therefore lose the disabled row and escape as an unhandled rejection. The vendored writer now serializes writes, retries only transient access/busy failures with bounded backoff, observes every rejection, and drains the latest write at teardown; the real Loader composition injects one `EPERM` and proves the durable retry. Codex 0.146 advertised `exec_command` to the loopback Responses model on Windows but rejected the returned call in its own router, the same upstream failure class tracked in [openai/codex#31665](https://github.com/openai/codex/issues/31665). Development evidence is pinned to the current stable 0.147.0 release: regenerated upstream schemas preserve the provider-owned handshake, thread/turn, approval, user-input, and elicitation contract, while the real-product suite again proves command rejection without a side effect and whole-tree exit. +Two product boundaries required foundation repairs. Include's debounced config persistence previously launched an unobserved promise from a timer; a transient Windows `EPERM` while replacing `cordis.yml` could therefore lose the disabled row and escape as an unhandled rejection. The vendored writer now serializes writes, retries only transient access/busy failures with bounded backoff, observes every rejection, and drains the latest write at teardown; the real Loader composition injects one `EPERM` and proves the durable retry. Codex 0.146 advertised `exec_command` to the loopback Responses model on Windows but rejected the returned call in its own router, the same upstream failure class tracked in [openai/codex#31665](https://github.com/openai/codex/issues/31665). Development evidence is pinned to the current stable 0.147.0 release: regenerated upstream schemas preserve the provider-owned handshake, thread/turn, approval, user-input, and elicitation contract. Because Codex can advertise the legacy `shell_command` instead when unified exec is unavailable on the host, the loopback model now selects an advertised command tool and supplies that tool's argument shape rather than injecting `exec_command` unconditionally. The real-product suite therefore proves unattended rejection without a side effect and whole-tree exit through the product's actual default tool inventory on each host. + +The subsequent exact hosted run isolated seven other fixture contracts. The PowerShell background-output case now waits for process completion before draining and comparing the final delta, while the pi-ai idle-watchdog case retains a bounded one-second close deadline that accommodates the delayed Windows socket notification. The asynchronous workspace projection seeds its in-memory filesystem at the host-resolved root. The Include retry acceptance asserts the injected failure and eventual persistence rather than an incidental total rename count, which may include another valid serialized write. LSP's bare-command fixture supplies a `.cmd` executable through `PATHEXT` on Windows, and URI rendering expectations distinguish the execution world's path convention from the test host's separators. None of these changes skips a supported path or weakens the asserted outcome. POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. No supported product source is excluded from Windows coverage to accommodate these differences. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 4c71f3eeda..6f23dd1df1 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -32,7 +32,9 @@ Status: implemented 下一次完整覆盖率运行触及的是 6 项相互独立的末端故障,不再是同一问题的连锁结果。React 队列动作覆盖现在会在 awaited `act()` 中解析模拟请求,再观察渲染完成后的状态。未闭合 Markdown 工作负载保留全部 6,400 个候选项,并采用显式的 3 秒覆盖率预算;异步工作区投影告警测试则为外层用例设置 20 秒预算,大于其 10 秒轮询预算。真实 Claude Code 拆卸会在所有受管句柄均报告退出后,采用带 10 次有界重试的异步递归删除,以容纳 Windows 延迟释放句柄的行为,同时不削弱完全停稳断言。 -另有两个产品边界需要基础性修复。Include 的防抖配置持久化此前会从计时器启动一个无人观察的 Promise;Windows 在替换 `cordis.yml` 时若瞬时返回 `EPERM`,既可能丢失已禁用行,也会让 rejection 以未处理形式逸出。现在,vendored writer 会串行化写入,只对瞬时的访问/忙碌错误执行有界退避重试,观察每个 rejection,并在拆卸时排空最新写入;真实 Loader 组合测试会注入一次 `EPERM` 并证明持久化重试。Codex 0.146 在 Windows 上会把 `exec_command` 提供给回环 Responses 模型,却在自身路由器中拒绝模型返回的调用;这与 [openai/codex#31665](https://github.com/openai/codex/issues/31665) 跟踪的上游故障属于同一类。开发证据现锁定当前稳定版 0.147.0:重新生成的上游 schema 保留了提供方拥有的握手、线程/轮次、审批、用户输入和 elicitation 契约;真实产品测试则再次证明命令会在不产生副作用的情况下被拒绝,且整棵进程树退出。 +另有两个产品边界需要基础性修复。Include 的防抖配置持久化此前会从计时器启动一个无人观察的 Promise;Windows 在替换 `cordis.yml` 时若瞬时返回 `EPERM`,既可能丢失已禁用行,也会让 rejection 以未处理形式逸出。现在,vendored writer 会串行化写入,只对瞬时的访问/忙碌错误执行有界退避重试,观察每个 rejection,并在拆卸时排空最新写入;真实 Loader 组合测试会注入一次 `EPERM` 并证明持久化重试。Codex 0.146 在 Windows 上会把 `exec_command` 提供给回环 Responses 模型,却在自身路由器中拒绝模型返回的调用;这与 [openai/codex#31665](https://github.com/openai/codex/issues/31665) 跟踪的上游故障属于同一类。开发证据现锁定当前稳定版 0.147.0:重新生成的上游 schema 保留了提供方拥有的握手、线程/轮次、审批、用户输入和 elicitation 契约。当宿主无法使用 unified exec 时,Codex 可能改为提供旧版 `shell_command`;因此,回环模型现在会选择实际提供的命令工具,并使用该工具对应的参数形态,而不再无条件注入 `exec_command`。真实产品测试由此会通过各宿主的实际默认工具清单,证明无人值守拒绝不产生副作用,且整棵进程树退出。 + +随后的分支头精确托管运行又隔离出另外 7 项 fixture 契约。PowerShell 后台输出场景现在会等待进程完成,再排空并比较最后一段增量;pi-ai 空闲 watchdog 场景则保留 1 秒的有界关闭期限,以容纳 Windows 延迟送达的 socket 通知。异步工作区投影会在宿主解析后的根目录上填充内存文件系统。Include 重试验收现在断言注入的故障与最终持久化结果,而不再断言可能包含另一项合法串行写入的偶然 rename 总次数。LSP 的裸命令 fixture 会在 Windows 上通过 `PATHEXT` 提供 `.cmd` 可执行文件,URI 渲染预期也会区分执行环境的路径约定与测试宿主的分隔符。上述修改既没有跳过受支持路径,也没有削弱结果断言。 POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。没有任何受支持的产品源码为适应这些差异而从 Windows 覆盖率中排除。 diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index 780491d696..5c5c7fa35e 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -314,9 +314,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' env: { BG_VAR: 'bg-env' }, dshEnv: { DSH_BG_VAR: 'bg-dsh-env' }, })) - const output = await readUntil(proc, '[bg-env][bg-dsh-env]') - expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n') + const partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]') await proc.done + const output = partialOutput + lf(proc.readOutput().delta) + expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n') expect(proc.exitCode).toBe(0) }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 3f52b19920..59c5b39d10 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -4113,11 +4113,12 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(RecordingFileSystem) await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem - const agent = stubAgent('/') + const root = resolve('/') + const agent = stubAgent(root) const failure = new Error('projection failed') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - fs.entries.set('/.git', { type: 'directory' }) - fs.entries.set('/AGENTS.md', { type: 'file', content: 'workspace rule' }) + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'workspace rule' }) vi.spyOn(agent.inbox, 'prepend').mockImplementationOnce(() => { throw failure }) ctx.emit('tools/result', stubToolExecution({ diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 88f16687fa..4420e54da4 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -22,7 +22,7 @@ import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' import * as DirectoryPickerAuto from '../src/index.ts' -const renameControl = vi.hoisted(() => ({ attempts: 0, remainingFailures: 0 })) +const renameControl = vi.hoisted(() => ({ attempts: 0, injectedFailures: 0, remainingFailures: 0 })) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -32,6 +32,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { renameControl.attempts++ if (renameControl.remainingFailures > 0) { renameControl.remainingFailures-- + renameControl.injectedFailures++ throw Object.assign(new Error(`transient rename failure for ${newPath}`), { code: 'EPERM' }) } await actual.rename(oldPath, newPath) @@ -59,6 +60,7 @@ afterEach(async () => { root = undefined fakeBin = undefined renameControl.attempts = 0 + renameControl.injectedFailures = 0 renameControl.remainingFailures = 0 }) @@ -187,6 +189,8 @@ describe('real Loader composition', () => { expect(entryNames(ctx)).not.toContain(NATIVE) // Same self-dispose persistence as above: let the write land before teardown. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') - expect(renameControl.attempts).toBe(2) + expect(renameControl.injectedFailures).toBe(1) + expect(renameControl.remainingFailures).toBe(0) + expect(renameControl.attempts).toBeGreaterThanOrEqual(2) }) }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index d2e101505d..a8a06a167d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -272,7 +272,7 @@ describe('PiAiAdapter provider routing', () => { await Promise.race([ server.responseClosed, new Promise((_resolve, reject) => { - setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 100) + setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 1_000) }), ]) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 88b2b0144f..24edca4795 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -38,9 +38,9 @@ describe('lsp-local provider resolution', () => { // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it. const bin = join(root, 'bin') await mkdir(bin) - const exe = join(bin, 'fake-lsp') - await writeFile(exe, '#!/bin/sh\nexit 0\n') - await chmod(exe, 0o755) + const exe = join(bin, process.platform === 'win32' ? 'fake-lsp.cmd' : 'fake-lsp') + await writeFile(exe, process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n') + if (process.platform !== 'win32') await chmod(exe, 0o755) const ctx = new Context() await ctx.plugin(Lsp) @@ -49,7 +49,7 @@ describe('lsp-local provider resolution', () => { await expect(ctx.plugin(LspLocal, config('onpath', { command: 'fake-lsp', args: [], - env: { PATH: bin }, + env: { PATH: bin, ...process.platform === 'win32' ? { PATHEXT: '.CMD' } : {} }, extensionToLanguage: { '.ts': 'typescript' }, }))).resolves.toBeDefined() await ctx.fiber.dispose() diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts index 2d181f25b3..51fb6a5083 100644 --- a/packages/lsp/tool-lsp/tests/render.spec.ts +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -55,7 +55,7 @@ describe('renderUri', () => { it('returns an absolute path for a file: URI outside the workspace', () => { const outside = resolve(WS, '..', 'other', 'lib', 'b.ts') const uri = pathToFileURL(outside).href - expect(renderUri(uri, WS_URI)).toBe(outside) + expect(renderUri(uri, WS_URI)).toBe(outside.replaceAll('\\', '/')) }) it('renders the workspace root itself as "."', () => { @@ -83,7 +83,7 @@ describe('renderUri', () => { }) it('preserves backslashes as ordinary POSIX filename characters', () => { - expect(renderUri('file:///home/u/proj/dir%5Cname/a.ts', WS_URI)).toBe('dir\\name/a.ts') + expect(renderUri('file:///home/u/proj/dir%5Cname/a.ts', 'file:///home/u/proj')).toBe('dir\\name/a.ts') }) it('keeps malformed or mismatched URI coordinates verbatim', () => { diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 9c3de65229..c39093b041 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -174,18 +174,32 @@ describe('real @openai/codex 0.147.0 product', () => { }, 60_000) it('cancels a real app-server command approval without executing the command', async () => { - const { harness, fixture } = await realHarness([ + const command = process.platform === 'win32' + ? 'cmd /c type nul > approval-side-effect' + : 'touch approval-side-effect' + const commandCalls = [ { - kind: 'functionCall', name: 'exec_command', arguments: { - cmd: process.platform === 'win32' - ? 'cmd /c type nul > approval-side-effect' - : 'touch approval-side-effect', + cmd: command, sandbox_permissions: 'require_escalated', justification: 'exercise the unattended approval boundary', }, }, + { + name: 'shell_command', + arguments: { + command, + sandbox_permissions: 'require_escalated', + justification: 'exercise the unattended approval boundary', + }, + }, + ] as const + const { harness, fixture } = await realHarness([ + { + kind: 'advertisedFunctionCall', + choices: commandCalls, + }, ]) const sideEffect = join(harness.workspace, 'approval-side-effect') const run = await harness.ctx.subagents.start('codex', { @@ -202,9 +216,9 @@ describe('real @openai/codex 0.147.0 product', () => { expect(existsSync(sideEffect)).toBe(false) expect(fixture.requests).toHaveLength(1) const tools = fixture.requests[0]!.body.tools as Array> - expect(tools).toEqual(expect.arrayContaining([ - expect.objectContaining({ type: 'function', name: 'exec_command' }), - ])) + expect(commandCalls.some(call => tools.some(tool => ( + tool.type === 'function' && tool.name === call.name + )))).toBe(true) expect(fixture.requests.every(requestEntry => requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', )).toBe(true) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index 2c7e5534d0..2b6e5868ae 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -22,6 +22,13 @@ export type ResponsesBehavior = readonly name: string readonly arguments: Record } + | { + readonly kind: 'advertisedFunctionCall' + readonly choices: readonly { + readonly name: string + readonly arguments: Record + }[] + } | { readonly kind: 'hold' } /** Running package-private Responses fixture. */ @@ -218,6 +225,18 @@ function closeServer(server: Server): Promise { }) } +function advertisedFunctionNames(body: Record): Set { + if (!Array.isArray(body.tools)) return new Set() + return new Set(body.tools.flatMap((tool): string[] => ( + tool !== null + && typeof tool === 'object' + && (tool as Record).type === 'function' + && typeof (tool as Record).name === 'string' + ? [(tool as Record).name as string] + : [] + ))) +} + /** * Start a loopback-only Responses SSE fixture. * @param script - one behavior per expected Responses request. @@ -234,11 +253,12 @@ export async function startResponsesFixture( openResponses.add(response) response.on('close', () => { openResponses.delete(response) }) void readRequest(request).then((body) => { + const parsedBody = JSON.parse(body) as Record requests.push({ method: request.method, path: request.url, headers: request.headers, - body: JSON.parse(body) as Record, + body: parsedBody, }) started.resolve(undefined) const behavior = behaviors.shift() @@ -247,6 +267,14 @@ export async function startResponsesFixture( response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } })) return } + const advertisedCall = behavior.kind === 'advertisedFunctionCall' + ? behavior.choices.find(choice => advertisedFunctionNames(parsedBody).has(choice.name)) + : undefined + if (behavior.kind === 'advertisedFunctionCall' && advertisedCall === undefined) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } })) + return + } response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', @@ -254,9 +282,15 @@ export async function startResponsesFixture( 'x-request-id': 'req_fixture', }) if (behavior.kind === 'hold') return - const events = behavior.kind === 'complete' - ? completeResponsesEvents(behavior.text) - : functionCallEvents(behavior.name, behavior.arguments) + let events: Record[] + if (behavior.kind === 'complete') { + events = completeResponsesEvents(behavior.text) + } else { + const call = behavior.kind === 'functionCall' + ? behavior + : advertisedCall! + events = functionCallEvents(call.name, call.arguments) + } for (const event of events) { response.write(`data: ${JSON.stringify(event)}\n\n`) }