From 84ba35f27d865520102bad2b8f4a5f2c26506b52 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 11:51:23 +0800 Subject: [PATCH 01/59] refactor(utils): remove unused createChildAbortController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产端零调用,仅被单测引用;combineAbortSignals 已覆盖同类场景。 一并移除对应的单测用例。 --- packages/cli/src/utils/abort.ts | 33 ----------------- packages/cli/tests/unit/utils/abort.test.ts | 40 +-------------------- 2 files changed, 1 insertion(+), 72 deletions(-) diff --git a/packages/cli/src/utils/abort.ts b/packages/cli/src/utils/abort.ts index f00af734c..0f1d2005d 100644 --- a/packages/cli/src/utils/abort.ts +++ b/packages/cli/src/utils/abort.ts @@ -21,39 +21,6 @@ export function isAbortError(error: unknown): boolean { return false; } -/** - * 创建一个子 AbortController,当父 signal abort 时子也 abort(单向传播)。 - * - 父已 aborted → 子立即 abort(快速路径) - * - 子 abort 时自动从父移除 listener,防止内存泄漏 - * - 传播 abort reason - */ -export function createChildAbortController(parentSignal: AbortSignal): AbortController { - const child = new AbortController(); - - // 快速路径:父已 aborted - if (parentSignal.aborted) { - child.abort(parentSignal.reason); - return child; - } - - const onParentAbort = () => { - child.abort(parentSignal.reason); - }; - - parentSignal.addEventListener('abort', onParentAbort, { once: true }); - - // 子 abort 时清理父上的 listener - child.signal.addEventListener( - 'abort', - () => { - parentSignal.removeEventListener('abort', onParentAbort); - }, - { once: true } - ); - - return child; -} - /** * 合并多个 AbortSignal — 任一触发则合并后的 signal 也触发。 * 修复了旧实现中 fallback 分支丢失 reason 的问题。 diff --git a/packages/cli/tests/unit/utils/abort.test.ts b/packages/cli/tests/unit/utils/abort.test.ts index eff403b4d..f3f83184b 100644 --- a/packages/cli/tests/unit/utils/abort.test.ts +++ b/packages/cli/tests/unit/utils/abort.test.ts @@ -8,15 +8,13 @@ * 4. 任一 signal abort 触发合并 signal * 5. abort reason 正确传播 * 6. abort 后 listener 被清理(无泄漏) - * 7. createChildAbortController 基本行为 - * 8. abortableSleep 基本行为 + * 7. abortableSleep 基本行为 */ import { describe, expect, it, vi } from 'vitest'; import { abortableSleep, combineAbortSignals, - createChildAbortController, isAbortError, } from '../../../src/utils/abort.js'; @@ -92,42 +90,6 @@ describe('combineAbortSignals', () => { }); }); -describe('createChildAbortController', () => { - it('父 abort 传播到子', () => { - const parent = new AbortController(); - const child = createChildAbortController(parent.signal); - - expect(child.signal.aborted).toBe(false); - parent.abort('parent-reason'); - expect(child.signal.aborted).toBe(true); - expect(child.signal.reason).toBe('parent-reason'); - }); - - it('父已 aborted 时子立即 abort', () => { - const parent = new AbortController(); - parent.abort('already'); - const child = createChildAbortController(parent.signal); - expect(child.signal.aborted).toBe(true); - expect(child.signal.reason).toBe('already'); - }); - - it('子 abort 不影响父', () => { - const parent = new AbortController(); - const child = createChildAbortController(parent.signal); - child.abort('child-only'); - expect(parent.signal.aborted).toBe(false); - expect(child.signal.aborted).toBe(true); - }); - - it('子 abort 后清理父上的 listener', () => { - const parent = new AbortController(); - const spy = vi.spyOn(parent.signal, 'removeEventListener'); - const child = createChildAbortController(parent.signal); - child.abort('cleanup'); - expect(spy).toHaveBeenCalled(); - }); -}); - describe('abortableSleep', () => { it('正常完成', async () => { await expect(abortableSleep(10)).resolves.toBeUndefined(); From 8bcd269689c5ce4b1f9ca9c7093b964d1610a0ef Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 12:19:43 +0800 Subject: [PATCH 02/59] refactor(tools): consolidate subagent delegation deps into a container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 与 Team 工具原本以 8 个位置参数(Task)和散开的 options 字段(Team) 重复传递同一组委派依赖,getBuiltinTools 还需逐字段转发。引入声明式的 SubagentDelegationDeps 容器统一承载 registry、资源快照与偏好选择器, 以命名依赖注入替代样板透传,并清理由此产生的死 import。 - createTaskTool 改为接收容器,函数体通过解构保持不变 - TeamToolOptions 继承容器,去掉重复且误名的 subagentRegistry 字段 - getBuiltinTools 单次构建容器并复用,公共 opts 签名不变 --- packages/cli/src/tools/builtin/index.ts | 37 +++++++--------- .../tools/builtin/subagentDelegationDeps.ts | 44 +++++++++++++++++++ packages/cli/src/tools/builtin/task/task.ts | 26 ++++++----- .../cli/src/tools/builtin/team/teamTools.ts | 27 ++---------- .../agent-runtime/agent/task-resume.test.ts | 14 +++--- .../services/workspace-agent-tools.test.ts | 8 +++- .../tooling/tools/builtin/team-tools.test.ts | 2 +- 7 files changed, 91 insertions(+), 67 deletions(-) create mode 100644 packages/cli/src/tools/builtin/subagentDelegationDeps.ts diff --git a/packages/cli/src/tools/builtin/index.ts b/packages/cli/src/tools/builtin/index.ts index e6d358389..62777466d 100644 --- a/packages/cli/src/tools/builtin/index.ts +++ b/packages/cli/src/tools/builtin/index.ts @@ -41,6 +41,7 @@ import { enterPlanModeTool, exitPlanModeTool } from './plan/index.js'; import { globTool, grepTool } from './search/index.js'; // Shell 命令工具 import { bashTool, killShellTool, writeStdinTool } from './shell/index.js'; +import type { SubagentDelegationDeps } from './subagentDelegationDeps.js'; // System 工具 import { askUserQuestionTool, @@ -99,6 +100,18 @@ export async function getBuiltinTools(opts?: { opts?.commandRegistry ?? CustomCommandRegistry.getInstance(resourceRoot); + // 委派给子 Agent 的 session 级依赖,Task 与 Team 工具共享同一份声明。 + const delegationDeps: SubagentDelegationDeps = { + registry: subagentRegistry, + agentResources: opts?.agentResources, + modelResources: opts?.modelResources, + lspResources: opts?.lspResources, + getReasoningEffort: opts?.getReasoningEffort, + getServiceTier: opts?.getServiceTier, + getResponseVerbosity: opts?.getResponseVerbosity, + getCommunicationStyle: opts?.getCommunicationStyle, + }; + const builtinTools = [ // 文件操作工具: Read, Edit, Write, ApplyPatch, NotebookEdit readTool, @@ -122,16 +135,7 @@ export async function getBuiltinTools(opts?: { ...(opts?.browserRuntime ? createBrowserTools(opts.browserRuntime) : []), // 子代理任务: Task, TaskOutput - createTaskTool( - subagentRegistry, - opts?.agentResources, - opts?.modelResources, - opts?.lspResources, - opts?.getReasoningEffort, - opts?.getServiceTier, - opts?.getResponseVerbosity, - opts?.getCommunicationStyle - ), + createTaskTool(delegationDeps), taskOutputTool, // 会话任务列表: TaskCreate, TaskGet, TaskUpdate, TaskList @@ -151,18 +155,7 @@ export async function getBuiltinTools(opts?: { // Agent Teams are a formal capability gated by explicit configuration. ...(opts?.agentTeamsEnabled - ? createTeamTools({ - sessionId, - configDir, - subagentRegistry, - agentResources: opts?.agentResources, - modelResources: opts?.modelResources, - lspResources: opts?.lspResources, - getReasoningEffort: opts?.getReasoningEffort, - getServiceTier: opts?.getServiceTier, - getResponseVerbosity: opts?.getResponseVerbosity, - getCommunicationStyle: opts?.getCommunicationStyle, - }) + ? createTeamTools({ sessionId, configDir, ...delegationDeps }) : []), // Worktree isolation: EnterWorktree, ExitWorktree diff --git a/packages/cli/src/tools/builtin/subagentDelegationDeps.ts b/packages/cli/src/tools/builtin/subagentDelegationDeps.ts new file mode 100644 index 000000000..05cba4f48 --- /dev/null +++ b/packages/cli/src/tools/builtin/subagentDelegationDeps.ts @@ -0,0 +1,44 @@ +/** + * Subagent 委派依赖容器 + * + * Task 与 Team 工具在把工作委派给子 Agent 时,需要同一组 session 级依赖: + * 子代理注册表、workspace 资源快照(agent/model/lsp)以及当前的模型偏好选择器。 + * + * 过去这些依赖以 8 个位置参数(Task)或散开的 options 字段(Team)分别传递, + * 既容易错位,也让 getBuiltinTools 不得不逐字段转发。这里用一个声明式容器统一承载, + * 让委派入口以命名依赖注入替代样板透传。 + */ + +import type { SessionAgentResources } from '../../agent/resources/WorkspaceAgentResources.js'; +import type { SessionModelResources } from '../../agent/resources/WorkspaceModelResources.js'; +import type { SubagentRegistry } from '../../agent/subagents/SubagentRegistry.js'; +import type { + CommunicationStyleSelection, + ReasoningEffortSelection, + ResponseVerbositySelection, + ServiceTierSelection, +} from '../../config/types.js'; +import type { SessionLspResources } from '../../lsp/WorkspaceLspResources.js'; + +/** + * 委派给子 Agent 所需的 session 级依赖。 + * 全部可选:缺省时各工具回退到进程级 registry 或继承调用方偏好。 + */ +export interface SubagentDelegationDeps { + /** 子代理定义注册表;缺省时回退到进程级单例。 */ + registry?: SubagentRegistry; + /** 按 source project 隔离的 Agent 资源快照。 */ + agentResources?: SessionAgentResources; + /** 按 source project 隔离的模型资源快照。 */ + modelResources?: SessionModelResources; + /** 按 source project 隔离的 LSP 资源快照。 */ + lspResources?: SessionLspResources; + /** 读取当前 reasoning effort 选择。 */ + getReasoningEffort?: () => ReasoningEffortSelection; + /** 读取当前 service tier 选择。 */ + getServiceTier?: () => ServiceTierSelection; + /** 读取当前响应详尽度选择。 */ + getResponseVerbosity?: () => ResponseVerbositySelection; + /** 读取当前沟通风格选择。 */ + getCommunicationStyle?: () => CommunicationStyleSelection; +} diff --git a/packages/cli/src/tools/builtin/task/task.ts b/packages/cli/src/tools/builtin/task/task.ts index 2e2ee0c00..7c5746d4e 100644 --- a/packages/cli/src/tools/builtin/task/task.ts +++ b/packages/cli/src/tools/builtin/task/task.ts @@ -60,6 +60,7 @@ import { createSessionId } from '../../../utils/sessionId.js'; import { createTool } from '../../core/createTool.js'; import type { ExecutionContext, ToolResult } from '../../types/index.js'; import { ToolErrorType, ToolKind } from '../../types/index.js'; +import type { SubagentDelegationDeps } from '../subagentDelegationDeps.js'; /** * 从错误中提取用户友好的错误信息 @@ -160,16 +161,17 @@ Usage notes: * - 模型从 subagent 描述中选择合适的类型 * - 每个 subagent 有独立的系统提示和工具配置 */ -export function createTaskTool( - registry: SubagentRegistry = getSubagentRegistry(), - agentResources?: SessionAgentResources, - modelResources?: SessionModelResources, - lspResources?: SessionLspResources, - getReasoningEffort?: () => ReasoningEffortSelection, - getServiceTier?: () => ServiceTierSelection, - getResponseVerbosity?: () => ResponseVerbositySelection, - getCommunicationStyle?: () => CommunicationStyleSelection -) { +export function createTaskTool(deps: SubagentDelegationDeps = {}) { + const { + registry = getSubagentRegistry(), + agentResources, + modelResources, + lspResources, + getReasoningEffort, + getServiceTier, + getResponseVerbosity, + getCommunicationStyle, + } = deps; return createTool({ name: 'Task', displayName: 'Subagent Scheduler', @@ -450,8 +452,8 @@ export function createTaskTool( }); } -/** @deprecated Use createTaskTool(registry). */ -export const taskTool = createTaskTool(subagentRegistry); +/** @deprecated Use createTaskTool({ registry }). */ +export const taskTool = createTaskTool({ registry: subagentRegistry }); interface SubagentEventBridge { onStarted: () => void; diff --git a/packages/cli/src/tools/builtin/team/teamTools.ts b/packages/cli/src/tools/builtin/team/teamTools.ts index 473ef4a4a..b1b2f13cf 100644 --- a/packages/cli/src/tools/builtin/team/teamTools.ts +++ b/packages/cli/src/tools/builtin/team/teamTools.ts @@ -1,27 +1,16 @@ -import type { SessionAgentResources } from '../../../agent/resources/WorkspaceAgentResources.js'; -import type { SessionModelResources } from '../../../agent/resources/WorkspaceModelResources.js'; -import { - getSubagentRegistry, - type SubagentRegistry, -} from '../../../agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../agent/subagents/SubagentRegistry.js'; import { MAX_TEAM_MEMBERS, MAX_TEAM_TASKS, TeamRuntime, } from '../../../agent/teams/TeamRuntime.js'; -import type { - CommunicationStyleSelection, - ReasoningEffortSelection, - ResponseVerbositySelection, - ServiceTierSelection, -} from '../../../config/types.js'; import { getBladeStorageRoot } from '../../../context/storage/pathUtils.js'; -import type { SessionLspResources } from '../../../lsp/WorkspaceLspResources.js'; import { Default, StringEnum, Type } from '../../../schema/index.js'; import { getCwd } from '../../../utils/cwd.js'; import { createTool } from '../../core/createTool.js'; import type { ExecutionContext, ToolResult } from '../../types/index.js'; import { ToolErrorType, ToolKind } from '../../types/index.js'; +import type { SubagentDelegationDeps } from '../subagentDelegationDeps.js'; const memberSchema = Type.Object({ name: Type.String({ @@ -67,17 +56,9 @@ const taskSchema = Type.Object({ ), }); -interface TeamToolOptions { +interface TeamToolOptions extends SubagentDelegationDeps { sessionId?: string; configDir?: string; - subagentRegistry?: SubagentRegistry; - agentResources?: SessionAgentResources; - modelResources?: SessionModelResources; - lspResources?: SessionLspResources; - getReasoningEffort?: () => ReasoningEffortSelection; - getServiceTier?: () => ServiceTierSelection; - getResponseVerbosity?: () => ResponseVerbositySelection; - getCommunicationStyle?: () => CommunicationStyleSelection; } export function createTeamTools(options: TeamToolOptions = {}) { @@ -85,7 +66,7 @@ export function createTeamTools(options: TeamToolOptions = {}) { const configDir = options.configDir || getBladeStorageRoot(); const runtime = new TeamRuntime({ configDir, - subagentRegistry: options.subagentRegistry ?? getSubagentRegistry(), + subagentRegistry: options.registry ?? getSubagentRegistry(), agentResources: options.agentResources, modelResources: options.modelResources, lspResources: options.lspResources, diff --git a/packages/cli/tests/unit/agent-runtime/agent/task-resume.test.ts b/packages/cli/tests/unit/agent-runtime/agent/task-resume.test.ts index 01c89648c..c29812528 100644 --- a/packages/cli/tests/unit/agent-runtime/agent/task-resume.test.ts +++ b/packages/cli/tests/unit/agent-runtime/agent/task-resume.test.ts @@ -194,16 +194,16 @@ describe('Task durable subagent resume protocol', () => { projectRoot: workspaceA, servers: { typescript: { command: 'server' } }, } as never; - const tool = createTaskTool( - subagentRegistry, + const tool = createTaskTool({ + registry: subagentRegistry, agentResources, modelResources, lspResources, - () => 'high', - () => 'fast', - () => 'high', - () => 'explanatory' - ); + getReasoningEffort: () => 'high', + getServiceTier: () => 'fast', + getResponseVerbosity: () => 'high', + getCommunicationStyle: () => 'explanatory', + }); await tool .build({ diff --git a/packages/cli/tests/unit/services/workspace-agent-tools.test.ts b/packages/cli/tests/unit/services/workspace-agent-tools.test.ts index 218ae9930..d63f9d822 100644 --- a/packages/cli/tests/unit/services/workspace-agent-tools.test.ts +++ b/packages/cli/tests/unit/services/workspace-agent-tools.test.ts @@ -27,8 +27,12 @@ describe('workspace-bound agent tools', () => { first.register({ name: 'agent-a', description: 'Agent A only' }); second.register({ name: 'agent-b', description: 'Agent B only' }); - const firstDescription = String(createTaskTool(first).description.long); - const secondDescription = String(createTaskTool(second).description.long); + const firstDescription = String( + createTaskTool({ registry: first }).description.long + ); + const secondDescription = String( + createTaskTool({ registry: second }).description.long + ); expect(firstDescription).toContain('agent-a'); expect(firstDescription).not.toContain('agent-b'); diff --git a/packages/cli/tests/unit/tooling/tools/builtin/team-tools.test.ts b/packages/cli/tests/unit/tooling/tools/builtin/team-tools.test.ts index 2ee3f84ce..429b7694a 100644 --- a/packages/cli/tests/unit/tooling/tools/builtin/team-tools.test.ts +++ b/packages/cli/tests/unit/tooling/tools/builtin/team-tools.test.ts @@ -465,7 +465,7 @@ describe('agent team tools', () => { servers: { typescript: { command: 'server' } }, } as never; const teamCreateTool = getTool(configDir, 'TeamCreate', { - subagentRegistry, + registry: subagentRegistry, agentResources, modelResources, lspResources, From 8b2d4ea06f4388b07a3b48b682a78ee6458e680b Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 12:29:25 +0800 Subject: [PATCH 03/59] refactor: remove unused subsystems and dependencies --- bun.lock | 16 - package.json | 4 - packages/cli/src/browser/index.ts | 9 - .../context/processors/ContextCompressor.ts | 338 --------------- .../src/context/processors/ContextFilter.ts | 397 ------------------ .../cli/src/context/storage/CacheStore.ts | 319 -------------- .../cli/src/context/storage/MemoryStore.ts | 187 --------- packages/cli/src/goals/index.ts | 48 --- packages/cli/src/memory/index.ts | 18 - .../src/tools/execution/ExecutionPipeline.ts | 10 - packages/cli/web/package.json | 4 +- packages/cli/web/src/components/ui/tabs.tsx | 53 --- scripts/bench-sqlite.mts | 207 --------- 13 files changed, 1 insertion(+), 1609 deletions(-) delete mode 100644 packages/cli/src/browser/index.ts delete mode 100644 packages/cli/src/context/processors/ContextCompressor.ts delete mode 100644 packages/cli/src/context/processors/ContextFilter.ts delete mode 100644 packages/cli/src/context/storage/CacheStore.ts delete mode 100644 packages/cli/src/context/storage/MemoryStore.ts delete mode 100644 packages/cli/src/goals/index.ts delete mode 100644 packages/cli/src/memory/index.ts delete mode 100644 packages/cli/src/tools/execution/ExecutionPipeline.ts delete mode 100644 packages/cli/web/src/components/ui/tabs.tsx delete mode 100644 scripts/bench-sqlite.mts diff --git a/bun.lock b/bun.lock index 0fb063997..3dfe8a040 100644 --- a/bun.lock +++ b/bun.lock @@ -7,11 +7,7 @@ "devDependencies": { "@biomejs/biome": "2.5.7", "@types/node": "^25.5.0", - "@types/react": "19.2.10", - "@types/react-dom": "19.2.3", "knip": "^5.80.0", - "react": "19.2.4", - "react-dom": "19.2.4", "typescript": "^5.9.2", }, }, @@ -103,7 +99,6 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.0.2", - "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", @@ -118,7 +113,6 @@ "remark-gfm": "^4.0.1", "tailwind-merge": "^2.2.0", "tailwindcss-animate": "^1.0.7", - "typebox": "1.3.7", }, "devDependencies": { "@biomejs/biome": "2.5.7", @@ -486,8 +480,6 @@ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "optionalDependencies": { "@types/react": "19.2.10", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "optionalDependencies": { "@types/react": "19.2.10", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "optionalDependencies": { "@types/react": "19.2.10" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "optionalDependencies": { "@types/react": "19.2.10" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], @@ -514,14 +506,10 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "optionalDependencies": { "@types/react": "19.2.10", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "19.2.10", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "19.2.10", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "optionalDependencies": { "@types/react": "19.2.10" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], - "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "19.2.10", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], - "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "optionalDependencies": { "@types/react": "19.2.10", "@types/react-dom": "19.2.3" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "optionalDependencies": { "@types/react": "19.2.10" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -2190,8 +2178,6 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "6.2.3", "string-width": "5.1.2", "strip-ansi": "7.1.2" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "optionalDependencies": { "@types/react": "19.2.10" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "optionalDependencies": { "@types/react": "19.2.10" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "optionalDependencies": { "@types/react": "19.2.10" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2246,8 +2232,6 @@ "blade-vscode/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], - "blade-web/typebox": ["typebox@1.3.7", "", {}, "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg=="], - "blade-web/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], "body-parser/iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], diff --git a/package.json b/package.json index 44290f897..0e40268be 100644 --- a/package.json +++ b/package.json @@ -71,11 +71,7 @@ "devDependencies": { "@biomejs/biome": "2.5.7", "@types/node": "^25.5.0", - "@types/react": "19.2.10", - "@types/react-dom": "19.2.3", "knip": "^5.80.0", - "react": "19.2.4", - "react-dom": "19.2.4", "typescript": "^5.9.2" }, "trustedDependencies": [ diff --git a/packages/cli/src/browser/index.ts b/packages/cli/src/browser/index.ts deleted file mode 100644 index 4843ddc47..000000000 --- a/packages/cli/src/browser/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './BrowserArtifactStore.js'; -export * from './BrowserInstallation.js'; -export * from './BrowserOperationGate.js'; -export * from './BrowserProcessPool.js'; -export * from './BrowserSecurity.js'; -export * from './BrowserSnapshotAuthority.js'; -export * from './constants.js'; -export * from './SessionBrowserRuntime.js'; -export * from './types.js'; diff --git a/packages/cli/src/context/processors/ContextCompressor.ts b/packages/cli/src/context/processors/ContextCompressor.ts deleted file mode 100644 index 439037d52..000000000 --- a/packages/cli/src/context/processors/ContextCompressor.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { CompressedContext, ContextData, ContextMessage, ToolCall } from '../types.js'; - -/** - * 上下文压缩器 - 智能压缩上下文以节省 token 使用 - */ -export class ContextCompressor { - private readonly maxSummaryLength: number; - private readonly keyPointsLimit: number; - private readonly recentMessagesLimit: number; - - constructor( - maxSummaryLength: number = 500, - keyPointsLimit: number = 10, - recentMessagesLimit: number = 20 - ) { - this.maxSummaryLength = maxSummaryLength; - this.keyPointsLimit = keyPointsLimit; - this.recentMessagesLimit = recentMessagesLimit; - } - - /** - * 压缩上下文数据 - */ - async compress(contextData: ContextData): Promise { - const messages = contextData.layers.conversation.messages; - const toolCalls = contextData.layers.tool.recentCalls; - - // 分离系统消息和用户/助手消息 - const systemMessages = messages.filter((m) => m.role === 'system'); - const conversationMessages = messages.filter((m) => m.role !== 'system'); - - // 获取最近的消息(保持完整) - const recentMessages = this.getRecentMessages(conversationMessages); - - // 压缩较旧的消息 - const olderMessages = conversationMessages.slice(0, -this.recentMessagesLimit); - const summary = await this.generateSummary(olderMessages); - - // 提取关键要点 - const keyPoints = this.extractKeyPoints(olderMessages, toolCalls); - - // 生成工具摘要 - const toolSummary = this.generateToolSummary(toolCalls); - - // 估算 token 数量 - const tokenCount = this.estimateTokenCount( - summary, - keyPoints, - recentMessages, - toolSummary - ); - - return { - summary, - keyPoints, - recentMessages: [...systemMessages, ...recentMessages], - toolSummary, - tokenCount, - }; - } - - /** - * 获取最近的消息 - */ - private getRecentMessages(messages: ContextMessage[]): ContextMessage[] { - return messages.slice(-this.recentMessagesLimit); - } - - /** - * 生成对话摘要 - */ - private async generateSummary(messages: ContextMessage[]): Promise { - if (messages.length === 0) { - return ''; - } - - // 简单的摘要生成策略(可以后续接入 LLM 进行更智能的摘要) - const topics = new Set(); - const actions = new Set(); - const decisions = new Set(); - - for (const message of messages) { - const content = message.content.toLowerCase(); - - // 检测主题关键词 - const topicKeywords = ['关于', '讨论', '问题', '项目', '功能', '需求']; - topicKeywords.forEach((keyword) => { - if (content.includes(keyword)) { - const context = this.extractContext(content, keyword, 50); - if (context) topics.add(context); - } - }); - - // 检测动作关键词 - const actionKeywords = ['创建', '删除', '修改', '更新', '实现', '开发']; - actionKeywords.forEach((keyword) => { - if (content.includes(keyword)) { - const context = this.extractContext(content, keyword, 30); - if (context) actions.add(context); - } - }); - - // 检测决策关键词 - const decisionKeywords = ['决定', '选择', '确定', '采用', '使用']; - decisionKeywords.forEach((keyword) => { - if (content.includes(keyword)) { - const context = this.extractContext(content, keyword, 40); - if (context) decisions.add(context); - } - }); - } - - // 构建摘要 - let summary = `对话涉及 ${messages.length} 条消息。`; - - if (topics.size > 0) { - summary += ` 主要讨论:${Array.from(topics).slice(0, 3).join('、')}。`; - } - - if (actions.size > 0) { - summary += ` 执行操作:${Array.from(actions).slice(0, 3).join('、')}。`; - } - - if (decisions.size > 0) { - summary += ` 关键决策:${Array.from(decisions).slice(0, 2).join('、')}。`; - } - - return summary.length > this.maxSummaryLength - ? summary.substring(0, this.maxSummaryLength) + '...' - : summary; - } - - /** - * 提取关键要点 - */ - private extractKeyPoints( - messages: ContextMessage[], - toolCalls: ToolCall[] - ): string[] { - const keyPoints: Set = new Set(); - - // 从消息中提取关键点 - for (const message of messages) { - if (message.role === 'user') { - // 用户的问题和请求 - const questions = this.extractQuestions(message.content); - questions.forEach((q) => keyPoints.add(`用户问题:${q}`)); - - const requests = this.extractRequests(message.content); - requests.forEach((r) => keyPoints.add(`用户请求:${r}`)); - } else if (message.role === 'assistant') { - // 助手的重要建议和解决方案 - const solutions = this.extractSolutions(message.content); - solutions.forEach((s) => keyPoints.add(`解决方案:${s}`)); - } - } - - // 从工具调用中提取关键点 - const toolUsage = this.summarizeToolUsage(toolCalls); - toolUsage.forEach((usage) => keyPoints.add(`工具使用:${usage}`)); - - return Array.from(keyPoints).slice(0, this.keyPointsLimit); - } - - /** - * 生成工具调用摘要 - */ - private generateToolSummary(toolCalls: ToolCall[]): string { - if (toolCalls.length === 0) { - return ''; - } - - const toolStats = new Map< - string, - { count: number; success: number; recent: number } - >(); - const recentTime = Date.now() - 10 * 60 * 1000; // 最近10分钟 - - for (const call of toolCalls) { - const stats = toolStats.get(call.name) || { count: 0, success: 0, recent: 0 }; - stats.count++; - if (call.status === 'success') stats.success++; - if (call.timestamp > recentTime) stats.recent++; - toolStats.set(call.name, stats); - } - - const summaryParts: string[] = []; - for (const [toolName, stats] of Array.from(toolStats.entries())) { - const successRate = Math.round((stats.success / stats.count) * 100); - summaryParts.push(`${toolName}(${stats.count}次,成功率${successRate}%)`); - } - - return `工具调用:${summaryParts.join('、')}`; - } - - /** - * 估算 token 数量(简单估算) - */ - private estimateTokenCount( - summary: string, - keyPoints: string[], - recentMessages: ContextMessage[], - toolSummary?: string - ): number { - let totalLength = summary.length + keyPoints.join(' ').length; - - if (toolSummary) { - totalLength += toolSummary.length; - } - - for (const message of recentMessages) { - totalLength += message.content.length; - } - - // 粗略估算:4个字符 ≈ 1个 token(对于中文) - return Math.ceil(totalLength / 4); - } - - /** - * 从内容中提取上下文 - */ - private extractContext( - content: string, - keyword: string, - maxLength: number - ): string | null { - const index = content.indexOf(keyword); - if (index === -1) return null; - - const start = Math.max(0, index - maxLength / 2); - const end = Math.min(content.length, index + maxLength / 2); - - return content.substring(start, end).trim(); - } - - /** - * 提取问题 - */ - private extractQuestions(content: string): string[] { - const questions: string[] = []; - const questionMarkers = ['?', '?', '如何', '怎么', '什么', '为什么']; - - const sentences = content.split(/[。!.!]/); - for (const sentence of sentences) { - if (questionMarkers.some((marker) => sentence.includes(marker))) { - const cleaned = sentence.trim(); - if (cleaned.length > 5 && cleaned.length < 100) { - questions.push(cleaned); - } - } - } - - return questions.slice(0, 3); // 最多返回3个问题 - } - - /** - * 提取请求 - */ - private extractRequests(content: string): string[] { - const requests: string[] = []; - const requestMarkers = ['请', '帮我', '需要', '想要', '希望', '能否']; - - const sentences = content.split(/[。!.!]/); - for (const sentence of sentences) { - if (requestMarkers.some((marker) => sentence.includes(marker))) { - const cleaned = sentence.trim(); - if (cleaned.length > 5 && cleaned.length < 100) { - requests.push(cleaned); - } - } - } - - return requests.slice(0, 3); // 最多返回3个请求 - } - - /** - * 提取解决方案 - */ - private extractSolutions(content: string): string[] { - const solutions: string[] = []; - const solutionMarkers = ['可以', '建议', '推荐', '应该', '最好', '解决方案']; - - const sentences = content.split(/[。!.!]/); - for (const sentence of sentences) { - if (solutionMarkers.some((marker) => sentence.includes(marker))) { - const cleaned = sentence.trim(); - if (cleaned.length > 10 && cleaned.length < 150) { - solutions.push(cleaned); - } - } - } - - return solutions.slice(0, 3); // 最多返回3个解决方案 - } - - /** - * 总结工具使用情况 - */ - private summarizeToolUsage(toolCalls: ToolCall[]): string[] { - const summary: string[] = []; - const recentCalls = toolCalls.filter( - (call) => Date.now() - call.timestamp < 30 * 60 * 1000 // 最近30分钟 - ); - - if (recentCalls.length > 0) { - const toolGroups = new Map(); - recentCalls.forEach((call) => { - const group = toolGroups.get(call.name) || []; - group.push(call); - toolGroups.set(call.name, group); - }); - - for (const [toolName, calls] of Array.from(toolGroups.entries())) { - const successCount = calls.filter((c) => c.status === 'success').length; - summary.push(`${toolName}(${calls.length}次,${successCount}成功)`); - } - } - - return summary.slice(0, 5); // 最多返回5个工具使用摘要 - } - - /** - * 检查是否需要压缩 - */ - shouldCompress(contextData: ContextData, maxTokens: number): boolean { - const estimatedTokens = this.estimateCurrentTokens(contextData); - return estimatedTokens > maxTokens * 0.8; // 当超过80%限制时开始压缩 - } - - /** - * 估算当前上下文的 token 数量 - */ - private estimateCurrentTokens(contextData: ContextData): number { - const messages = contextData.layers.conversation.messages; - const totalLength = messages.reduce((sum, msg) => sum + msg.content.length, 0); - return Math.ceil(totalLength / 4); // 简单估算 - } -} diff --git a/packages/cli/src/context/processors/ContextFilter.ts b/packages/cli/src/context/processors/ContextFilter.ts deleted file mode 100644 index 41e0952d1..000000000 --- a/packages/cli/src/context/processors/ContextFilter.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { - ContextData, - ContextMessage, - ContextFilter as FilterOptions, -} from '../types.js'; - -/** - * 上下文过滤器 - 根据配置过滤和筛选上下文内容 - */ -export class ContextFilter { - private readonly defaultOptions: Required; - - constructor(defaultOptions?: FilterOptions) { - this.defaultOptions = { - maxTokens: 32000, - maxMessages: 50, - timeWindow: 24 * 60 * 60 * 1000, // 24小时 - priority: 1, - includeTools: true, - includeWorkspace: true, - ...defaultOptions, - }; - } - - /** - * 过滤上下文数据 - */ - filter(contextData: ContextData, options?: FilterOptions): ContextData { - const filterOptions = { ...this.defaultOptions, ...options }; - - const filteredData: ContextData = { - layers: { - system: contextData.layers.system, - session: contextData.layers.session, - conversation: this.filterConversation( - contextData.layers.conversation, - filterOptions - ), - tool: filterOptions.includeTools - ? this.filterTools(contextData.layers.tool, filterOptions) - : { recentCalls: [], toolStates: {}, dependencies: {} }, - workspace: filterOptions.includeWorkspace - ? contextData.layers.workspace - : { currentFiles: [], recentFiles: [], environment: {} }, - }, - metadata: { - ...contextData.metadata, - lastUpdated: Date.now(), - }, - }; - - // 重新计算 token 数量 - filteredData.metadata.totalTokens = this.estimateTokens(filteredData); - - return filteredData; - } - - /** - * 过滤对话上下文 - */ - private filterConversation( - conversation: ContextData['layers']['conversation'], - options: Required - ): ContextData['layers']['conversation'] { - let filteredMessages = [...conversation.messages]; - - // 时间窗口过滤 - if (options.timeWindow > 0) { - const cutoffTime = Date.now() - options.timeWindow; - filteredMessages = filteredMessages.filter( - (msg) => msg.timestamp >= cutoffTime || msg.role === 'system' - ); - } - - // 优先级过滤 - if (options.priority > 1) { - filteredMessages = this.filterByPriority(filteredMessages, options.priority); - } - - // 消息数量限制 - if (options.maxMessages > 0) { - filteredMessages = this.limitMessages(filteredMessages, options.maxMessages); - } - - // Token 数量限制 - if (options.maxTokens > 0) { - filteredMessages = this.limitByTokens(filteredMessages, options.maxTokens); - } - - return { - messages: filteredMessages, - summary: conversation.summary, - topics: this.updateTopics(filteredMessages, conversation.topics), - lastActivity: conversation.lastActivity, - }; - } - - /** - * 过滤工具上下文 - */ - private filterTools( - toolContext: ContextData['layers']['tool'], - options: Required - ): ContextData['layers']['tool'] { - let filteredCalls = [...toolContext.recentCalls]; - - // 时间窗口过滤 - if (options.timeWindow > 0) { - const cutoffTime = Date.now() - options.timeWindow; - filteredCalls = filteredCalls.filter((call) => call.timestamp >= cutoffTime); - } - - // 保留最近的成功调用和失败调用(用于学习) - const successCalls = filteredCalls.filter((call) => call.status === 'success'); - const failedCalls = filteredCalls.filter((call) => call.status === 'error'); - - // 限制每种状态的调用数量 - const maxSuccessfulCalls = Math.min(20, successCalls.length); - const maxFailedCalls = Math.min(10, failedCalls.length); - - const limitedCalls = [ - ...successCalls.slice(-maxSuccessfulCalls), - ...failedCalls.slice(-maxFailedCalls), - ].sort((a, b) => a.timestamp - b.timestamp); - - return { - recentCalls: limitedCalls, - toolStates: toolContext.toolStates, - dependencies: toolContext.dependencies, - }; - } - - /** - * 按优先级过滤消息 - */ - private filterByPriority( - messages: ContextMessage[], - minPriority: number - ): ContextMessage[] { - return messages.filter((msg) => { - // 系统消息始终保留 - if (msg.role === 'system') return true; - - // 计算消息优先级 - const priority = this.calculateMessagePriority(msg); - return priority >= minPriority; - }); - } - - /** - * 计算消息优先级 - */ - private calculateMessagePriority(message: ContextMessage): number { - let priority = 1; - - // 基于角色的基础分数 - if (message.role === 'system') priority += 3; - else if (message.role === 'assistant') priority += 1; - - // 基于内容的分数 - const content = message.content.toLowerCase(); - - // 包含重要关键词 - const importantKeywords = ['错误', '警告', '重要', '关键', '问题', '解决']; - if (importantKeywords.some((keyword) => content.includes(keyword))) { - priority += 2; - } - - // 包含代码或技术内容 - if ( - content.includes('```') || - content.includes('function') || - content.includes('class') - ) { - priority += 1; - } - - // 基于时间的衰减(最近的消息优先级更高) - const ageInHours = (Date.now() - message.timestamp) / (60 * 60 * 1000); - if (ageInHours < 1) priority += 2; - else if (ageInHours < 6) priority += 1; - - return priority; - } - - /** - * 限制消息数量 - */ - private limitMessages( - messages: ContextMessage[], - maxMessages: number - ): ContextMessage[] { - if (messages.length <= maxMessages) { - return messages; - } - - // 分离系统消息和其他消息 - const systemMessages = messages.filter((msg) => msg.role === 'system'); - const otherMessages = messages.filter((msg) => msg.role !== 'system'); - - // 保留系统消息和最近的其他消息 - const remainingSlots = maxMessages - systemMessages.length; - const limitedOtherMessages = - remainingSlots > 0 ? otherMessages.slice(-remainingSlots) : []; - - return [...systemMessages, ...limitedOtherMessages].sort( - (a, b) => a.timestamp - b.timestamp - ); - } - - /** - * 按 Token 数量限制消息 - */ - private limitByTokens( - messages: ContextMessage[], - maxTokens: number - ): ContextMessage[] { - if (maxTokens <= 0) return messages; - - let totalTokens = 0; - const result: ContextMessage[] = []; - - // 从最新消息开始向前计算 - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - const messageTokens = this.estimateMessageTokens(message); - - if (message.role === 'system') { - // 系统消息必须包含,如果空间不够则压缩 - if (totalTokens + messageTokens <= maxTokens) { - result.unshift(message); - totalTokens += messageTokens; - } else { - const compressedMessage = this.compressMessage( - message, - maxTokens - totalTokens - ); - result.unshift(compressedMessage); - totalTokens += this.estimateMessageTokens(compressedMessage); - } - } else if (totalTokens + messageTokens <= maxTokens) { - result.unshift(message); - totalTokens += messageTokens; - } else { - break; - } - } - - return result.sort((a, b) => a.timestamp - b.timestamp); - } - - /** - * 估算消息的 Token 数量 - */ - private estimateMessageTokens(message: ContextMessage): number { - // 简单估算:4个字符约等于1个token(中文) - return Math.ceil(message.content.length / 4); - } - - /** - * 压缩消息内容 - */ - private compressMessage(message: ContextMessage, maxTokens: number): ContextMessage { - const maxLength = maxTokens * 4; // 粗略换算为字符数 - - if (message.content.length <= maxLength) { - return message; - } - - const compressed = message.content.substring(0, maxLength - 3) + '...'; - - return { - ...message, - content: compressed, - metadata: { - ...message.metadata, - compressed: true, - originalLength: message.content.length, - }, - }; - } - - /** - * 更新主题列表 - */ - private updateTopics(messages: ContextMessage[], originalTopics: string[]): string[] { - const topics = new Set(originalTopics); - - // 从过滤后的消息中提取新主题 - for (const message of messages) { - const extractedTopics = this.extractTopicsFromMessage(message); - extractedTopics.forEach((topic) => topics.add(topic)); - } - - return Array.from(topics).slice(0, 10); // 最多保留10个主题 - } - - /** - * 从消息中提取主题 - */ - private extractTopicsFromMessage(message: ContextMessage): string[] { - const content = message.content.toLowerCase(); - const topics: string[] = []; - - // 简单的主题提取逻辑 - const topicKeywords = [ - '项目', - '功能', - '模块', - '组件', - '服务', - '接口', - '数据库', - '前端', - '后端', - '算法', - '架构', - '设计', - ]; - - topicKeywords.forEach((keyword) => { - if (content.includes(keyword)) { - topics.push(keyword); - } - }); - - return topics; - } - - /** - * 估算上下文数据的总 Token 数量 - */ - private estimateTokens(contextData: ContextData): number { - let totalTokens = 0; - - // 对话消息 - for (const message of contextData.layers.conversation.messages) { - totalTokens += this.estimateMessageTokens(message); - } - - // 系统上下文 - const systemContent = JSON.stringify(contextData.layers.system); - totalTokens += Math.ceil(systemContent.length / 4); - - // 工具上下文 - if (contextData.layers.tool.recentCalls.length > 0) { - const toolContent = JSON.stringify(contextData.layers.tool); - totalTokens += Math.ceil(toolContent.length / 8); // 工具调用数据通常更简洁 - } - - return totalTokens; - } - - /** - * 创建预设过滤器 - */ - static createPresets() { - return { - // 轻量级过滤器 - 适合快速响应 - lightweight: new ContextFilter({ - maxTokens: 1000, - maxMessages: 10, - timeWindow: 2 * 60 * 60 * 1000, // 2小时 - includeTools: false, - includeWorkspace: false, - }), - - // 标准过滤器 - 平衡性能和功能 - standard: new ContextFilter({ - maxTokens: 4000, - maxMessages: 30, - timeWindow: 12 * 60 * 60 * 1000, // 12小时 - includeTools: true, - includeWorkspace: true, - }), - - // 完整过滤器 - 包含所有上下文 - comprehensive: new ContextFilter({ - maxTokens: 8000, - maxMessages: 100, - timeWindow: 24 * 60 * 60 * 1000, // 24小时 - includeTools: true, - includeWorkspace: true, - }), - - // 调试过滤器 - 专注于错误和工具调用 - debug: new ContextFilter({ - maxTokens: 2000, - maxMessages: 20, - timeWindow: 6 * 60 * 60 * 1000, // 6小时 - priority: 2, // 只包含高优先级消息 - includeTools: true, - includeWorkspace: false, - }), - }; - } -} diff --git a/packages/cli/src/context/storage/CacheStore.ts b/packages/cli/src/context/storage/CacheStore.ts deleted file mode 100644 index 99576c225..000000000 --- a/packages/cli/src/context/storage/CacheStore.ts +++ /dev/null @@ -1,319 +0,0 @@ -import type { CompressedContext, ContextMessage } from '../types.js'; - -export interface CacheItem { - data: T; - timestamp: number; - accessCount: number; - lastAccess: number; - ttl: number; // Time to live in milliseconds -} - -/** - * LRU缓存实现 - 用于热点数据的快速访问 - */ -export class CacheStore { - private readonly cache: Map> = new Map(); - private readonly maxSize: number; - private readonly defaultTTL: number; - - constructor(maxSize: number = 100, defaultTTL: number = 5 * 60 * 1000) { - // 默认5分钟TTL - this.maxSize = maxSize; - this.defaultTTL = defaultTTL; - } - - /** - * 设置缓存项 - */ - set(key: string, data: T, ttl?: number): void { - const now = Date.now(); - const item: CacheItem = { - data, - timestamp: now, - accessCount: 0, - lastAccess: now, - ttl: ttl || this.defaultTTL, - }; - - // 如果缓存已满,删除最不常用的项 - if (this.cache.size >= this.maxSize && !this.cache.has(key)) { - this.evictLeastUsed(); - } - - this.cache.set(key, item); - } - - /** - * 获取缓存项 - */ - get(key: string): T | null { - const item = this.cache.get(key) as CacheItem | undefined; - - if (!item) { - return null; - } - - const now = Date.now(); - - // 检查是否过期 - if (now - item.timestamp > item.ttl) { - this.cache.delete(key); - return null; - } - - // 更新访问统计 - item.accessCount++; - item.lastAccess = now; - - return item.data; - } - - /** - * 检查缓存项是否存在 - */ - has(key: string): boolean { - const item = this.cache.get(key); - - if (!item) { - return false; - } - - // 检查是否过期 - if (Date.now() - item.timestamp > item.ttl) { - this.cache.delete(key); - return false; - } - - return true; - } - - /** - * 删除缓存项 - */ - delete(key: string): boolean { - return this.cache.delete(key); - } - - /** - * 清空缓存 - */ - clear(): void { - this.cache.clear(); - } - - /** - * 获取缓存大小 - */ - size(): number { - this.cleanExpired(); - return this.cache.size; - } - - /** - * 缓存消息摘要 - */ - cacheMessageSummary( - sessionId: string, - messages: ContextMessage[], - summary: string - ): void { - const key = `summary:${sessionId}:${messages.length}`; - this.set( - key, - { - summary, - messageCount: messages.length, - lastMessage: messages[messages.length - 1]?.timestamp || 0, - }, - 10 * 60 * 1000 - ); // 10分钟TTL - } - - /** - * 获取缓存的消息摘要 - */ - getMessageSummary( - sessionId: string, - messageCount: number - ): { - summary: string; - messageCount: number; - lastMessage: number; - } | null { - const key = `summary:${sessionId}:${messageCount}`; - return this.get(key); - } - - /** - * 缓存工具调用结果 - */ - cacheToolResult(toolName: string, input: unknown, result: unknown): void { - const inputHash = this.hashInput(input); - const key = `tool:${toolName}:${inputHash}`; - this.set(key, result, 30 * 60 * 1000); // 30分钟TTL - } - - /** - * 获取缓存的工具调用结果 - */ - getToolResult(toolName: string, input: unknown): unknown | null { - const inputHash = this.hashInput(input); - const key = `tool:${toolName}:${inputHash}`; - return this.get(key); - } - - /** - * 缓存上下文压缩结果 - */ - cacheCompressedContext(contextHash: string, compressed: CompressedContext): void { - const key = `compressed:${contextHash}`; - this.set(key, compressed, 15 * 60 * 1000); // 15分钟TTL - } - - /** - * 获取缓存的压缩上下文 - */ - getCompressedContext(contextHash: string): CompressedContext | null { - const key = `compressed:${contextHash}`; - return this.get(key); - } - - /** - * 获取缓存统计信息 - */ - getStats(): { - size: number; - maxSize: number; - hitRate: number; - memoryUsage: number; - topKeys: { key: string; accessCount: number; lastAccess: number }[]; - } { - this.cleanExpired(); - - let totalAccess = 0; - let memoryUsage = 0; - const keyStats: { key: string; accessCount: number; lastAccess: number }[] = []; - - for (const [key, item] of Array.from(this.cache.entries())) { - totalAccess += item.accessCount; - memoryUsage += this.estimateItemSize(item); - keyStats.push({ - key, - accessCount: item.accessCount, - lastAccess: item.lastAccess, - }); - } - - keyStats.sort((a, b) => b.accessCount - a.accessCount); - - return { - size: this.cache.size, - maxSize: this.maxSize, - hitRate: totalAccess > 0 ? totalAccess / (totalAccess + this.cache.size) : 0, - memoryUsage, - topKeys: keyStats.slice(0, 10), // 返回前10个最常访问的键 - }; - } - - /** - * 清理过期项 - */ - private cleanExpired(): void { - const now = Date.now(); - const expiredKeys: string[] = []; - - for (const [key, item] of Array.from(this.cache.entries())) { - if (now - item.timestamp > item.ttl) { - expiredKeys.push(key); - } - } - - expiredKeys.forEach((key) => this.cache.delete(key)); - } - - /** - * 驱逐最不常用的项 - */ - private evictLeastUsed(): void { - let leastUsedKey: string | null = null; - let leastScore = Infinity; - - const now = Date.now(); - - for (const [key, item] of Array.from(this.cache.entries())) { - // 计算使用分数(考虑访问次数和最后访问时间) - const recencyScore = 1 / (now - item.lastAccess + 1); - const frequencyScore = item.accessCount; - const score = recencyScore * frequencyScore; - - if (score < leastScore) { - leastScore = score; - leastUsedKey = key; - } - } - - if (leastUsedKey) { - this.cache.delete(leastUsedKey); - } - } - - /** - * 简单的输入哈希函数 - */ - private hashInput(input: unknown): string { - const str = JSON.stringify(input); - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32-bit integer - } - return Math.abs(hash).toString(36); - } - - /** - * 估算缓存项大小 - */ - private estimateItemSize(item: CacheItem): number { - try { - return JSON.stringify(item).length * 2; // 大概估算字节数 - } catch { - return 1000; // 默认估算 - } - } - - /** - * 设置缓存项的TTL - */ - setTTL(key: string, ttl: number): boolean { - const item = this.cache.get(key); - if (item) { - item.ttl = ttl; - item.timestamp = Date.now(); // 重置时间戳 - return true; - } - return false; - } - - /** - * 获取缓存项的剩余TTL - */ - getRemainingTTL(key: string): number { - const item = this.cache.get(key); - if (!item) { - return -1; - } - - const remaining = item.ttl - (Date.now() - item.timestamp); - return Math.max(0, remaining); - } - - /** - * 预热缓存(可用于启动时加载常用数据) - */ - warmup(data: { key: string; value: unknown; ttl?: number }[]): void { - data.forEach(({ key, value, ttl }) => { - this.set(key, value, ttl); - }); - } -} diff --git a/packages/cli/src/context/storage/MemoryStore.ts b/packages/cli/src/context/storage/MemoryStore.ts deleted file mode 100644 index 8399e281e..000000000 --- a/packages/cli/src/context/storage/MemoryStore.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { JsonValue } from '../../store/types.js'; -import { ContextData, ContextMessage, ToolCall, WorkspaceContext } from '../types.js'; - -/** - * 内存存储实现 - 用于当前会话的快速数据访问 - */ -export class MemoryStore { - private contextData: ContextData | null = null; - private readonly maxSize: number; - private readonly accessLog: Map = new Map(); - - constructor(maxSize: number = 1000) { - this.maxSize = maxSize; - } - - /** - * 存储上下文数据 - */ - setContext(data: ContextData): void { - this.contextData = { ...data }; - this.contextData.metadata.lastUpdated = Date.now(); - this.recordAccess('context'); - } - - /** - * 获取完整上下文数据 - */ - getContext(): ContextData | null { - if (this.contextData) { - this.recordAccess('context'); - } - return this.contextData; - } - - /** - * 添加消息到对话上下文 - */ - addMessage(message: ContextMessage): void { - if (!this.contextData) { - throw new Error('上下文数据未初始化'); - } - - this.contextData.layers.conversation.messages.push(message); - this.contextData.layers.conversation.lastActivity = Date.now(); - this.contextData.metadata.lastUpdated = Date.now(); - - // 检查是否超过大小限制 - this.enforceMemoryLimit(); - this.recordAccess('messages'); - } - - /** - * 获取最近的消息 - */ - getRecentMessages(count: number = 10): ContextMessage[] { - if (!this.contextData) { - return []; - } - - const messages = this.contextData.layers.conversation.messages; - this.recordAccess('messages'); - return messages.slice(-count); - } - - /** - * 添加工具调用记录 - */ - addToolCall(toolCall: ToolCall): void { - if (!this.contextData) { - throw new Error('上下文数据未初始化'); - } - - this.contextData.layers.tool.recentCalls.push(toolCall); - this.contextData.metadata.lastUpdated = Date.now(); - - // 保持工具调用历史在合理范围内 - if (this.contextData.layers.tool.recentCalls.length > 50) { - this.contextData.layers.tool.recentCalls = - this.contextData.layers.tool.recentCalls.slice(-25); - } - - this.recordAccess('tools'); - } - - /** - * 更新工具状态 - */ - updateToolState(toolName: string, state: JsonValue): void { - if (!this.contextData) { - throw new Error('上下文数据未初始化'); - } - - this.contextData.layers.tool.toolStates[toolName] = state; - this.contextData.metadata.lastUpdated = Date.now(); - this.recordAccess('tools'); - } - - /** - * 获取工具状态 - */ - getToolState(toolName: string): JsonValue | null { - if (!this.contextData) { - return null; - } - - this.recordAccess('tools'); - return this.contextData.layers.tool.toolStates[toolName]; - } - - /** - * 更新工作空间信息 - */ - updateWorkspace(updates: Partial): void { - if (!this.contextData) { - throw new Error('上下文数据未初始化'); - } - - Object.assign(this.contextData.layers.workspace, updates); - this.contextData.metadata.lastUpdated = Date.now(); - this.recordAccess('workspace'); - } - - /** - * 清除内存数据 - */ - clear(): void { - this.contextData = null; - this.accessLog.clear(); - } - - /** - * 获取内存使用情况 - */ - getMemoryInfo(): { - hasData: boolean; - messageCount: number; - toolCallCount: number; - lastUpdated: number | null; - } { - if (!this.contextData) { - return { - hasData: false, - messageCount: 0, - toolCallCount: 0, - lastUpdated: null, - }; - } - - return { - hasData: true, - messageCount: this.contextData.layers.conversation.messages.length, - toolCallCount: this.contextData.layers.tool.recentCalls.length, - lastUpdated: this.contextData.metadata.lastUpdated, - }; - } - - /** - * 记录访问日志 - */ - private recordAccess(key: string): void { - this.accessLog.set(key, Date.now()); - } - - /** - * 强制执行内存限制 - */ - private enforceMemoryLimit(): void { - if (!this.contextData) return; - - const messages = this.contextData.layers.conversation.messages; - if (messages.length > this.maxSize) { - // 保留最近的消息,删除较旧的 - const keepCount = Math.floor(this.maxSize * 0.8); // 保留80%的空间 - this.contextData.layers.conversation.messages = messages.slice(-keepCount); - } - } - - /** - * 估算内存使用量(以字符数为简单估算) - */ - getMemoryUsage(): number { - if (!this.contextData) return 0; - - const contextString = JSON.stringify(this.contextData); - return contextString.length; - } -} diff --git a/packages/cli/src/goals/index.ts b/packages/cli/src/goals/index.ts deleted file mode 100644 index ed9a28955..000000000 --- a/packages/cli/src/goals/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type { GoalExecutionFrontierPreparation } from './executionFrontier.js'; -export { - formatGoalExecutionFrontier, - getGoalTaskListId, - readGoalExecutionFrontier, -} from './executionFrontier.js'; -export type { GoalExecutionHostFailureAccumulator } from './executionHostFailure.js'; -export { - buildGoalExecutionHostFailurePrompt, - createGoalExecutionHostFailureAccumulator, - executionHostFailureForTerminalFailure, - isGoalExecutionHostFailureCategory, - observeGoalExecutionHostToolResult, - resolveGoalExecutionHostFailure, -} from './executionHostFailure.js'; -export { - classifyGoalFrontierStall, - formatGoalFrontierStall, -} from './frontierStall.js'; -export { GoalStore } from './GoalStore.js'; -export { detectGoalPrematureStop } from './prematureStop.js'; -export { buildGoalContinuationPrompt, formatGoalSummary } from './prompts.js'; -export type { - GoalChangeEvent, - GoalCreateInput, - GoalExecutionFrontier, - GoalExecutionHostFailureCategory, - GoalExecutionHostFailureState, - GoalFrontierStallCategory, - GoalFrontierStallInput, - GoalFrontierStallState, - GoalPrematureStopPattern, - GoalPrematureStopState, - GoalProgress, - GoalSnapshot, - GoalStatus, - GoalVerificationStallState, -} from './types.js'; -export { - GOAL_EXECUTION_HOST_FAILURE_CATEGORIES, - GOAL_FRONTIER_STALL_CATEGORIES, - GOAL_PREMATURE_STOP_PATTERNS, - MAX_CONSECUTIVE_GOAL_EXECUTION_HOST_FAILURES, - MAX_CONSECUTIVE_GOAL_FRONTIER_STALLS, - MAX_CONSECUTIVE_GOAL_PREMATURE_STOPS, - MAX_CONSECUTIVE_GOAL_VERIFICATION_STALLS, - MAX_GOAL_VERIFICATION_FEEDBACK_CHARS, -} from './types.js'; diff --git a/packages/cli/src/memory/index.ts b/packages/cli/src/memory/index.ts deleted file mode 100644 index 26cc5ddd0..000000000 --- a/packages/cli/src/memory/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { AutoMemoryManager } from './AutoMemoryManager.js'; -export type { - MemoryConsolidationEntry, - MemoryConsolidationPlan, -} from './MemoryConsolidation.js'; -export type { - MemoryConsolidationProjection, - MemoryConsolidationTopic, -} from '../api/memoryConsolidationSchemas.js'; -export { - commitMemoryConsolidation, - EMPTY_MEMORY_CONSOLIDATION_PLAN, - planMemoryConsolidation, -} from './MemoryConsolidation.js'; -export type { MemorySafetyResult } from './MemorySafety.js'; -export { classifyMemoryContent } from './MemorySafety.js'; -export type { AutoMemoryConfig, MemoryTopicInfo } from './types.js'; -export { DEFAULT_AUTO_MEMORY_CONFIG } from './types.js'; diff --git a/packages/cli/src/tools/execution/ExecutionPipeline.ts b/packages/cli/src/tools/execution/ExecutionPipeline.ts deleted file mode 100644 index 899ce4730..000000000 --- a/packages/cli/src/tools/execution/ExecutionPipeline.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @deprecated Import ToolExecutor from ToolExecutor.ts for new code. - */ -export { - type ExecutionStats, - ToolExecutor, - ToolExecutor as ExecutionPipeline, - type ToolExecutorConfig, - type ToolExecutorConfig as ExecutionPipelineConfig, -} from './ToolExecutor.js'; diff --git a/packages/cli/web/package.json b/packages/cli/web/package.json index cdfd57fa4..a38ed9d8a 100644 --- a/packages/cli/web/package.json +++ b/packages/cli/web/package.json @@ -17,7 +17,6 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.0.2", - "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", @@ -31,8 +30,7 @@ "react-syntax-highlighter": "^16.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^2.2.0", - "tailwindcss-animate": "^1.0.7", - "typebox": "1.3.7" + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@biomejs/biome": "2.5.7", diff --git a/packages/cli/web/src/components/ui/tabs.tsx b/packages/cli/web/src/components/ui/tabs.tsx deleted file mode 100644 index b1e17e6fa..000000000 --- a/packages/cli/web/src/components/ui/tabs.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as TabsPrimitive from '@radix-ui/react-tabs'; -import * as React from 'react'; - -import { cn } from '@/lib/utils'; - -const Tabs = TabsPrimitive.Root; - -const TabsList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -TabsList.displayName = TabsPrimitive.List.displayName; - -const TabsTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; - -const TabsContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -TabsContent.displayName = TabsPrimitive.Content.displayName; - -export { Tabs, TabsContent, TabsList, TabsTrigger }; diff --git a/scripts/bench-sqlite.mts b/scripts/bench-sqlite.mts deleted file mode 100644 index 916739438..000000000 --- a/scripts/bench-sqlite.mts +++ /dev/null @@ -1,207 +0,0 @@ -import { mkdtemp, rm, mkdir, writeFile, readdir, readFile, stat } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { openDb } from '../packages/cli/src/context/storage/sqlite/driver.ts'; -import { migrate } from '../packages/cli/src/context/storage/sqlite/schema.ts'; -import { - syncAll, - resetProjectionDbCache, -} from '../packages/cli/src/context/storage/sqlite/projection.ts'; -import { parseSessionJSONL } from '../packages/cli/src/context/storage/JSONLStore.ts'; -import { materializeSessionEvents } from '../packages/cli/src/services/sessionRewind.ts'; - -// ---- fixture generation ---- -function iso(sec: number): string { - return new Date(Date.UTC(2024, 0, 1, 0, 0, 0, 0) + sec * 1000).toISOString(); -} - -function buildTranscript(sessionId: string, cwd: string, turns: number): string { - const lines: string[] = []; - let seq = 0; - const push = (type: string, data: unknown, at: string) => - lines.push( - JSON.stringify({ - seq: ++seq, - id: `${sessionId}-${seq}`, - sessionId, - projectPath: cwd, - timestamp: at, - type, - cwd, - version: 't', - data, - }) - ); - push('session_created', { sessionId, rootId: sessionId, createdAt: iso(0), updatedAt: iso(0) }, iso(0)); - for (let t = 0; t < turns; t++) { - const u = `u${t}`; - const a = `a${t}`; - push('message_created', { messageId: u, role: 'user', createdAt: iso(t * 2) }, iso(t * 2)); - push('part_created', { partId: `p${u}`, messageId: u, partType: 'text', payload: { text: `question ${t} about topic keyword${t % 17}` }, createdAt: iso(t * 2) }, iso(t * 2)); - push('message_created', { messageId: a, role: 'assistant', parentMessageId: u, createdAt: iso(t * 2 + 1) }, iso(t * 2 + 1)); - push('part_created', { partId: `p${a}`, messageId: a, partType: 'text', payload: { text: `answer ${t} explaining details with keyword${t % 13} and more words to make it realistic length for a transcript line` }, createdAt: iso(t * 2 + 1) }, iso(t * 2 + 1)); - } - return lines.join('\n') + '\n'; -} - -// ---- JSONL baseline implementations (mirror current scan/search) ---- -async function jsonlListMetadata(projectsDir: string): Promise { - const dirs = await readdir(projectsDir); - let count = 0; - for (const dir of dirs) { - const pdir = path.join(projectsDir, dir); - let files: string[]; - try { - files = await readdir(pdir); - } catch { - continue; - } - for (const f of files) { - if (!f.endsWith('.jsonl')) continue; - const content = await readFile(path.join(pdir, f), 'utf8'); - const entries = materializeSessionEvents(parseSessionJSONL(content, f)); - // simulate metadata aggregation (message count) - count += entries.filter((e) => e.type === 'message_created').length; - } - } - return count; -} - -async function jsonlSearch(projectsDir: string, query: string): Promise { - const dirs = await readdir(projectsDir); - let hits = 0; - for (const dir of dirs) { - const pdir = path.join(projectsDir, dir); - let files: string[]; - try { - files = await readdir(pdir); - } catch { - continue; - } - for (const f of files) { - if (!f.endsWith('.jsonl')) continue; - const content = await readFile(path.join(pdir, f), 'utf8'); - const events = materializeSessionEvents(parseSessionJSONL(content, f)); - for (const e of events) { - if (e.type === 'part_created' && e.data.partType === 'text') { - const text = (e.data.payload as { text?: string }).text ?? ''; - if (text.toLowerCase().includes(query)) hits++; - } - } - } - } - return hits; -} - -function now(): number { - return Number(process.hrtime.bigint() / 1000n) / 1000; // ms -} - -async function median(fn: () => Promise, runs = 5): Promise { - const samples: number[] = []; - for (let i = 0; i < runs; i++) { - const t0 = now(); - await fn(); - samples.push(now() - t0); - } - samples.sort((a, b) => a - b); - return samples[Math.floor(samples.length / 2)]; -} - -const deriver = (entries: readonly any[], sessionId: string, projectPath: string) => { - const created = entries.find((e) => e.type === 'session_created'); - if (!created) return null; - const projected = materializeSessionEvents(entries as any); - const messageCount = projected.filter((e) => e.type === 'message_created').length; - const times = projected.map((e) => e.timestamp).sort(); - return { - sessionId, - projectPath, - rootId: sessionId, - taskStatus: 'completed', - title: sessionId, - messageCount, - firstMessageTime: times[0] ?? iso(0), - lastMessageTime: times[times.length - 1] ?? iso(0), - hasErrors: false, - }; -}; - -async function main() { - const scales = [50, 200, 1000]; - const turnsPerSession = 20; - const results: Array> = []; - - for (const N of scales) { - const root = await mkdtemp(path.join(os.tmpdir(), `blade-bench-${N}-`)); - process.env.BLADE_STORAGE_ROOT = root; - const projectsDir = path.join(root, 'projects'); - // spread across 5 project dirs - const projectCount = 5; - for (let p = 0; p < projectCount; p++) { - const cwd = `/bench/project-${p}`; - const escaped = cwd.replace(/[/\\]/g, '-').replace(/:/g, '_'); - const dir = path.join(projectsDir, escaped); - await mkdir(dir, { recursive: true }); - const per = Math.ceil(N / projectCount); - for (let s = 0; s < per; s++) { - const sessionId = `sess-${p}-${String(s).padStart(4, '0')}`; - await writeFile(path.join(dir, `${sessionId}.jsonl`), buildTranscript(sessionId, cwd, turnsPerSession), 'utf8'); - } - } - - // measure avg transcript size - const sampleDir = path.join(projectsDir, '-bench-project-0'); - const sampleFiles = await readdir(sampleDir); - const sampleStat = await stat(path.join(sampleDir, sampleFiles[0])); - - // ---- JSONL cold ---- - const jsonlList = await median(() => jsonlListMetadata(projectsDir)); - const jsonlSearchMs = await median(() => jsonlSearch(projectsDir, 'keyword5')); - - // ---- SQLite: first build (cold sync) then warm query ---- - resetProjectionDbCache(); - const db = await openDb(path.join(root, 'index.db')); - if (!db) throw new Error('no sqlite'); - migrate(db); - - const sqliteBuild = await median(async () => { - // force re-sync by clearing state each run for a fair "build" number - db.exec('DELETE FROM projection_state; DELETE FROM sessions; DELETE FROM parts; DELETE FROM parts_fts;'); - await syncAll(db, deriver as any); - }, 3); - - // warm list (mtime-gated syncAll is near-zero, then SELECT) - const sqliteList = await median(async () => { - await syncAll(db, deriver as any); - db.prepare('SELECT metadata_json FROM sessions').all(); - }); - const sqliteSearch = await median(async () => { - await syncAll(db, deriver as any); - db.prepare("SELECT session_id FROM parts_fts WHERE parts_fts MATCH ? LIMIT 20").all('keyword5*'); - }); - db.close(); - - results.push({ - N: String(N), - size: `${(sampleStat.size / 1024).toFixed(1)} KB/session`, - jsonlList: jsonlList.toFixed(1), - sqliteList: sqliteList.toFixed(1), - listSpeedup: `${(jsonlList / sqliteList).toFixed(1)}x`, - jsonlSearch: jsonlSearchMs.toFixed(1), - sqliteSearch: sqliteSearch.toFixed(1), - searchSpeedup: `${(jsonlSearchMs / sqliteSearch).toFixed(1)}x`, - sqliteBuild: sqliteBuild.toFixed(1), - }); - - await rm(root, { recursive: true, force: true }); - } - - console.log('\n=== RESULTS (median ms, ' + turnsPerSession + ' turns/session, 5 projects) ==='); - console.log(JSON.stringify(results, null, 2)); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); From d5beaa46859f7f92bbbfda06195200e54f215e8b Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 12:53:04 +0800 Subject: [PATCH 04/59] refactor(runtime): remove obsolete compatibility and context layers --- .../AcpFileRequestCoordinator.contracts.ts | 6 - packages/cli/src/acp/AcpServiceContext.ts | 183 ++----- packages/cli/src/acp/Session.ts | 2 +- packages/cli/src/agent/ExecutionEngine.ts | 40 -- .../cli/src/agent/runtime/SessionRuntime.ts | 5 - packages/cli/src/api/sideConversation.ts | 1 - packages/cli/src/bootstrap/state.ts | 4 - .../cli/src/browser/BrowserProcessPool.ts | 6 - .../src/config/sessionProjectionResidency.ts | 1 - packages/cli/src/context/ContextAssembler.ts | 240 --------- packages/cli/src/context/ContextManager.ts | 25 +- .../src/context/storage/PersistentStore.ts | 73 --- packages/cli/src/context/types.ts | 105 ---- packages/cli/src/mcp/loadMcpConfig.ts | 15 - .../cli/src/memory/MemoryConsolidation.ts | 18 - packages/cli/src/plugins/PluginManifest.ts | 18 - .../cli/src/plugins/PluginSourcePolicy.ts | 4 - .../src/services/StructuredOutputService.ts | 11 +- packages/cli/src/services/modelAlias.ts | 14 - .../src/services/pi/providerCircuitBreaker.ts | 2 - packages/cli/src/skills/SkillRegistry.ts | 10 - packages/cli/src/skills/index.ts | 7 +- .../cli/src/skills/injectSkillsMetadata.ts | 61 --- packages/cli/src/store/selectors/index.ts | 12 - packages/cli/src/store/vanilla.ts | 4 - .../cli/src/tools/builtin/system/index.ts | 4 +- .../cli/src/tools/builtin/system/skill.ts | 3 - .../src/tools/builtin/system/slashCommand.ts | 3 - .../src/ui/components/model-config/types.ts | 13 - .../cli/src/ui/utils/rawStreamRenderer.ts | 7 - .../agent/execution-engine.test.ts | 118 +---- .../agent/session-runtime.test.ts | 9 - .../context/turn-lifecycle.test.ts | 2 +- .../unit/context/ContextAssembler.test.ts | 456 ------------------ .../services/session-surface-service.test.ts | 4 +- 35 files changed, 62 insertions(+), 1424 deletions(-) delete mode 100644 packages/cli/src/context/ContextAssembler.ts delete mode 100644 packages/cli/src/skills/injectSkillsMetadata.ts delete mode 100644 packages/cli/tests/unit/context/ContextAssembler.test.ts diff --git a/packages/cli/src/acp/AcpFileRequestCoordinator.contracts.ts b/packages/cli/src/acp/AcpFileRequestCoordinator.contracts.ts index dea975f25..868468137 100644 --- a/packages/cli/src/acp/AcpFileRequestCoordinator.contracts.ts +++ b/packages/cli/src/acp/AcpFileRequestCoordinator.contracts.ts @@ -116,9 +116,3 @@ export function createAcpRemoteConnectionPathIdentity( .update(remotePath.collisionIdentity) .digest('hex')}`; } - -export function isAcpRemoteMutationRecoveryLease( - lease: AcpRemoteMutationLease | AcpRemoteMutationRecoveryLease | undefined -): lease is AcpRemoteMutationRecoveryLease { - return lease !== undefined && 'finish' in lease; -} diff --git a/packages/cli/src/acp/AcpServiceContext.ts b/packages/cli/src/acp/AcpServiceContext.ts index cfd49f918..b348ccbb8 100644 --- a/packages/cli/src/acp/AcpServiceContext.ts +++ b/packages/cli/src/acp/AcpServiceContext.ts @@ -9,9 +9,6 @@ import type { AgentSideConnection, ClientCapabilities, SessionNotification, - ToolCallContent, - ToolCallStatus, - ToolKind, } from '@agentclientprotocol/sdk'; import { type ForegroundProcessOwnership, @@ -778,18 +775,6 @@ export class AcpServiceContext { private static sessionRegistrationGeneration = 0n; private static currentSessionId: string | null = null; - private constructor() { - // 私有构造函数,使用静态方法 - } - - /** - * 获取单例实例(兼容旧 API) - * @deprecated 使用 getForSession(sessionId) 代替 - */ - static getInstance(): AcpServiceContext { - return new AcpServiceContext(); - } - /** * 初始化会话的 ACP 服务 * @@ -954,6 +939,35 @@ export class AcpServiceContext { return AcpServiceContext.sessions.get(sessionId)?.remoteFileSystem ?? false; } + static isAcpMode(): boolean { + return AcpServiceContext.currentSessionId !== null; + } + + static getFileSystemService(sessionId?: string): FileSystemService { + if (sessionId !== undefined) { + return ( + AcpServiceContext.sessions.get(sessionId)?.fileSystemService ?? + new UnavailableFileSystemService() + ); + } + const currentSessionId = AcpServiceContext.currentSessionId; + if (currentSessionId) { + const services = AcpServiceContext.sessions.get(currentSessionId); + if (services) return services.fileSystemService; + } + return new LocalFileSystemService(); + } + + static getTerminalService(sessionId?: string): TerminalService { + const targetSessionId = sessionId ?? AcpServiceContext.currentSessionId; + if (targetSessionId) { + const services = AcpServiceContext.sessions.get(targetSessionId); + if (services) return services.terminalService; + if (sessionId) return new UnavailableTerminalService(); + } + return new LocalTerminalService(); + } + static getRemoteSurfaceOwnerSnapshot( sessionId: string, descriptor: AcpRemoteWorkspaceDescriptorV1 @@ -983,150 +997,17 @@ export class AcpServiceContext { terminal: binding.terminal, }; } - - // ==================== 兼容旧 API(实例方法)==================== - - /** - * 初始化 ACP 服务(兼容旧 API) - * @deprecated 使用 AcpServiceContext.initializeSession() 代替 - */ - initialize( - connection: AgentSideConnection, - sessionId: string, - clientCapabilities: ClientCapabilities | undefined, - cwd?: string - ): void { - AcpServiceContext.initializeSession( - connection, - sessionId, - clientCapabilities, - cwd || getCwd() - ); - } - - /** - * 重置服务(兼容旧 API) - * @deprecated 使用 AcpServiceContext.destroySession(sessionId) 代替 - */ - reset(): void { - // 只重置当前会话,而不是所有会话 - if (AcpServiceContext.currentSessionId) { - AcpServiceContext.destroySession(AcpServiceContext.currentSessionId); - } - } - - /** - * 检查是否在 ACP 模式下运行 - */ - isAcpMode(): boolean { - return AcpServiceContext.currentSessionId !== null; - } - - /** - * 获取文件系统服务(当前会话) - */ - getFileSystemService(sessionId?: string): FileSystemService { - if (sessionId !== undefined) { - return ( - AcpServiceContext.sessions.get(sessionId)?.fileSystemService ?? - new UnavailableFileSystemService() - ); - } - const targetSessionId = sessionId ?? AcpServiceContext.currentSessionId; - if (targetSessionId) { - const services = AcpServiceContext.sessions.get(targetSessionId); - if (services) return services.fileSystemService; - } - return new LocalFileSystemService(); - } - - /** - * 获取终端服务(当前会话) - */ - getTerminalService(sessionId?: string): TerminalService { - const targetSessionId = sessionId ?? AcpServiceContext.currentSessionId; - if (targetSessionId) { - const services = AcpServiceContext.sessions.get(targetSessionId); - if (services) return services.terminalService; - if (sessionId) return new UnavailableTerminalService(); - } - return new LocalTerminalService(); - } - - /** - * 获取 ACP 连接(当前会话) - */ - getConnection(): AgentSideConnection | null { - if (AcpServiceContext.currentSessionId) { - const services = AcpServiceContext.sessions.get( - AcpServiceContext.currentSessionId - ); - if (services) return services.connection; - } - return null; - } - - /** - * 获取当前会话 ID - */ - getSessionId(): string | null { - return AcpServiceContext.currentSessionId; - } - - /** - * 获取客户端能力(当前会话) - */ - getClientCapabilities(): ClientCapabilities | null { - if (AcpServiceContext.currentSessionId) { - const services = AcpServiceContext.sessions.get( - AcpServiceContext.currentSessionId - ); - if (services) return services.clientCapabilities; - } - return null; - } - - /** - * 发送工具调用状态更新 - */ - async sendToolUpdate( - toolCallId: string, - status: ToolCallStatus, - title: string, - content?: ToolCallContent[], - kind?: ToolKind - ): Promise { - const sessionId = AcpServiceContext.currentSessionId; - if (!sessionId) return; - - const services = AcpServiceContext.sessions.get(sessionId); - if (!services) return; - - if (!services.sendUpdate) return; - try { - await services.sendUpdate({ - sessionUpdate: 'tool_call', - toolCallId, - status, - title, - content: content || [], - kind: kind || 'other', - }); - } catch (error) { - logger.warn('[AcpServiceContext] Failed to send tool update:', error); - } - } } /** * 便捷函数:获取终端服务 */ export function getAcpFileSystemService(sessionId?: string): FileSystemService { - return AcpServiceContext.getInstance().getFileSystemService(sessionId); + return AcpServiceContext.getFileSystemService(sessionId); } export function getTerminalService(sessionId?: string): TerminalService { - return AcpServiceContext.getInstance().getTerminalService(sessionId); + return AcpServiceContext.getTerminalService(sessionId); } /** @@ -1135,7 +1016,7 @@ export function getTerminalService(sessionId?: string): TerminalService { export function isAcpMode(sessionId?: string): boolean { return sessionId ? AcpServiceContext.getSessionServices(sessionId) !== null - : AcpServiceContext.getInstance().isAcpMode(); + : AcpServiceContext.isAcpMode(); } export function isAcpRemoteFileSystem(sessionId?: string): boolean { diff --git a/packages/cli/src/acp/Session.ts b/packages/cli/src/acp/Session.ts index c937119a2..3625a3e5e 100644 --- a/packages/cli/src/acp/Session.ts +++ b/packages/cli/src/acp/Session.ts @@ -534,7 +534,7 @@ export class AcpSession { const mcpServers = this.options.mcpServers ? toMcpServers(this.options.mcpServers) : undefined; - const terminalService = AcpServiceContext.getInstance().getTerminalService(this.id); + const terminalService = AcpServiceContext.getTerminalService(this.id); const workspace: SessionWorkspace | undefined = this.roots.kind === 'acp-remote' ? { diff --git a/packages/cli/src/agent/ExecutionEngine.ts b/packages/cli/src/agent/ExecutionEngine.ts index 4974fff7b..de938eaa1 100644 --- a/packages/cli/src/agent/ExecutionEngine.ts +++ b/packages/cli/src/agent/ExecutionEngine.ts @@ -3,7 +3,6 @@ * * 职责: * - 管理上下文(ContextManager) - * - 提供临时消息管理(MemoryAdapter) * - 执行简单任务 * * 注:并行执行由 LLM 自主决定(在一个回复中发起多个 Task 工具调用) @@ -15,22 +14,9 @@ import type { IChatService, Message } from '../services/ChatServiceInterface.js' import { getCwd } from '../utils/cwd.js'; import type { AgentResponse, AgentTask } from './types.js'; -/** - * 内存消息适配器 - 为向后兼容提供简单的消息管理 - * 注意:这只用于 ExecutionEngine 内部的临时消息管理 - * 实际的持久化由真实的 ContextManager 处理 - */ -export interface MemoryMessageAdapter { - getMessages(): Message[]; - addMessage(message: Message): void; - clearContext(): void; - getContextSize(): number; -} - export class ExecutionEngine { private chatService: IChatService; private contextManager: ContextManager; - private memoryAdapter: MemoryMessageAdapter; constructor( chatService: IChatService, @@ -45,25 +31,6 @@ export class ExecutionEngine { projectPath: projectPath || getCwd(), ...(stateStorage ? { stateStorage } : {}), }); - this.memoryAdapter = this.createMemoryAdapter(); - } - - /** - * 创建内存消息适配器(临时消息管理) - */ - private createMemoryAdapter(): MemoryMessageAdapter { - const messages: Message[] = []; - - return { - getMessages: () => [...messages], - addMessage: (message: Message) => { - messages.push(message); - }, - clearContext: () => { - messages.length = 0; - }, - getContextSize: () => messages.length, - }; } /** @@ -73,13 +40,6 @@ export class ExecutionEngine { return this.contextManager; } - /** - * 获取内存适配器(用于临时消息) - */ - public getMemoryAdapter(): MemoryMessageAdapter { - return this.memoryAdapter; - } - /** * 执行任务 */ diff --git a/packages/cli/src/agent/runtime/SessionRuntime.ts b/packages/cli/src/agent/runtime/SessionRuntime.ts index 01c8a1560..3e4ac5cb1 100644 --- a/packages/cli/src/agent/runtime/SessionRuntime.ts +++ b/packages/cli/src/agent/runtime/SessionRuntime.ts @@ -2927,11 +2927,6 @@ export class SessionRuntime { }); } - /** @deprecated Use createToolExecutor() for new code. */ - createExecutionPipeline(options: AgentOptions = {}): ToolExecutor { - return this.createToolExecutor(options); - } - async dispose(): Promise { if (this.disposePromise) { return this.disposePromise; diff --git a/packages/cli/src/api/sideConversation.ts b/packages/cli/src/api/sideConversation.ts index 5103260f1..068750032 100644 --- a/packages/cli/src/api/sideConversation.ts +++ b/packages/cli/src/api/sideConversation.ts @@ -1,4 +1,3 @@ -export const SIDE_CONVERSATION_COMMAND = 'btw'; export const MAX_SIDE_QUESTION_CHARS = 16 * 1024; export interface ParsedSideConversationCommand { diff --git a/packages/cli/src/bootstrap/state.ts b/packages/cli/src/bootstrap/state.ts index 009384902..29c3ff5f9 100644 --- a/packages/cli/src/bootstrap/state.ts +++ b/packages/cli/src/bootstrap/state.ts @@ -65,10 +65,6 @@ export function setOriginalCwd(newCwd: string): void { getState().originalCwd = newCwd.normalize('NFC'); } -export function getProjectRoot(): string { - return getState().projectRoot; -} - export function setProjectRoot(root: string): void { getState().projectRoot = root.normalize('NFC'); } diff --git a/packages/cli/src/browser/BrowserProcessPool.ts b/packages/cli/src/browser/BrowserProcessPool.ts index 0aff92fe1..fc5430df3 100644 --- a/packages/cli/src/browser/BrowserProcessPool.ts +++ b/packages/cli/src/browser/BrowserProcessPool.ts @@ -263,9 +263,3 @@ export function getBrowserProcessPool(): BrowserProcessPool { defaultPool ??= new BrowserProcessPool(); return defaultPool; } - -export async function disposeBrowserProcessPool(): Promise { - const pool = defaultPool; - defaultPool = undefined; - await pool?.dispose(); -} diff --git a/packages/cli/src/config/sessionProjectionResidency.ts b/packages/cli/src/config/sessionProjectionResidency.ts index 0a41cb88c..61f6e1d5b 100644 --- a/packages/cli/src/config/sessionProjectionResidency.ts +++ b/packages/cli/src/config/sessionProjectionResidency.ts @@ -8,7 +8,6 @@ export const MAX_SESSION_PROJECTION_IDLE_MS = 24 * 60 * 60_000; export const SESSION_PROJECTION_SWEEP_MS = 30_000; export const SESSION_PROJECTION_DRAIN_MS = 30_000; -export const MAX_SESSION_PROJECTION_WAKE_ENTRIES = 256; export function isValidResidentSessionProjectionLimit(value: number): boolean { return ( diff --git a/packages/cli/src/context/ContextAssembler.ts b/packages/cli/src/context/ContextAssembler.ts deleted file mode 100644 index f2e101727..000000000 --- a/packages/cli/src/context/ContextAssembler.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * ContextAssembler - 从 JSONL 事件流重建 ContextData - * - * 职责:将 PersistentStore 的 SessionEvent[] 转换为结构化的上下文数据。 - * 集中了之前散落在 PersistentStore.loadSession/loadConversation 和 - * ContextManager.loadSession 里的重建逻辑。 - */ - -import type { JsonObject, JsonValue, MessageRole } from '../store/types.js'; -import type { - ContextData, - ContextMessage, - ConversationContext, - SessionContext, - SessionEvent, - SystemContext, - ToolCall, - WorkspaceContext, -} from './types.js'; - -export interface AssembledSession { - session: SessionContext; - conversation: ConversationContext; - toolCalls: ToolCall[]; -} - -export class ContextAssembler { - /** - * 从 JSONL 事件流重建完整的会话数据 - */ - assemble(events: SessionEvent[]): AssembledSession | null { - if (events.length === 0) return null; - - const session = this.assembleSession(events); - const conversation = this.assembleConversation(events); - const toolCalls = this.assembleToolCalls(events); - - return { session, conversation, toolCalls }; - } - - /** - * 从事件流重建完整的 ContextData - */ - assembleContextData( - events: SessionEvent[], - system: SystemContext, - workspace: WorkspaceContext - ): ContextData | null { - const assembled = this.assemble(events); - if (!assembled) return null; - - return { - layers: { - system, - session: assembled.session, - conversation: assembled.conversation, - tool: { - recentCalls: assembled.toolCalls, - toolStates: {}, - dependencies: {}, - }, - workspace, - }, - metadata: { - totalTokens: 0, - priority: 1, - lastUpdated: Date.now(), - }, - }; - } - - /** - * 重建 SessionContext - */ - private assembleSession(events: SessionEvent[]): SessionContext { - const sessionCreated = events.find((e) => e.type === 'session_created'); - const sessionId = sessionCreated?.sessionId ?? events[0].sessionId; - const startTime = new Date( - sessionCreated?.timestamp ?? events[0].timestamp - ).getTime(); - - // 从 session_updated 事件中提取最新的配置 - const updates = events.filter((e) => e.type === 'session_updated'); - const latestUpdate = updates.length > 0 ? updates[updates.length - 1] : null; - - return { - sessionId, - userId: undefined, - preferences: {}, - configuration: (latestUpdate?.data as JsonObject) ?? {}, - startTime, - }; - } - - /** - * 重建 ConversationContext(包含 messages + summary) - */ - private assembleConversation(events: SessionEvent[]): ConversationContext { - const messageMap = new Map< - string, - { - id: string; - role: MessageRole; - content: string; - timestamp: number; - metadata?: JsonObject; - } - >(); - - let latestSummary: string | undefined; - - for (const event of events) { - if (event.type === 'message_created') { - messageMap.set(event.data.messageId, { - id: event.data.messageId, - role: event.data.role, - content: '', - timestamp: new Date(event.timestamp).getTime(), - metadata: - event.data.model || - event.data.inboxMessageId || - (event.data.metadata && - typeof event.data.metadata === 'object' && - !Array.isArray(event.data.metadata)) - ? { - ...(event.data.metadata && - typeof event.data.metadata === 'object' && - !Array.isArray(event.data.metadata) - ? event.data.metadata - : {}), - ...(event.data.model ? { model: event.data.model } : {}), - ...(event.data.inboxMessageId - ? { inboxMessageId: event.data.inboxMessageId } - : {}), - } - : undefined, - }); - } - - if (event.type === 'part_created') { - const { partType, messageId, payload } = event.data; - const message = messageMap.get(messageId); - - if (partType === 'text' && message) { - const p = payload as { text?: string }; - message.content = p.text ?? ''; - } - - if (partType === 'image' && message) { - message.content = message.content ? `${message.content}\n[Image]` : '[Image]'; - } - - // 提取 compaction summary - if (partType === 'summary') { - const p = payload as { text?: string }; - if (p.text) latestSummary = p.text; - } - } - - // part_updated 覆盖已有内容 - if (event.type === 'part_updated') { - const { partType, messageId, payload } = event.data; - const message = messageMap.get(messageId); - - if (partType === 'text' && message) { - const p = payload as { text?: string }; - message.content = p.text ?? ''; - } - } - } - - const messages: ContextMessage[] = Array.from(messageMap.values()); - const lastEvent = events[events.length - 1]; - const lastActivity = new Date(lastEvent.timestamp).getTime(); - - return { - messages, - summary: latestSummary, - topics: [], - lastActivity, - }; - } - - /** - * 重建 ToolCall 列表 - */ - private assembleToolCalls(events: SessionEvent[]): ToolCall[] { - const toolCalls = new Map(); - - for (const event of events) { - if (event.type !== 'part_created') continue; - - const { partType, partId, payload } = event.data; - - if (partType === 'tool_call') { - const p = payload as { - toolCallId?: string; - toolName?: string; - input?: JsonValue; - }; - const id = p.toolCallId ?? partId; - toolCalls.set(id, { - id, - name: p.toolName ?? 'unknown', - input: (p.input ?? null) as JsonValue, - timestamp: new Date(event.timestamp).getTime(), - status: 'pending', - }); - } - - if (partType === 'tool_result') { - const p = payload as { - toolCallId?: string; - toolName?: string; - output?: JsonValue; - error?: string | null; - }; - const id = p.toolCallId ?? partId; - const existing = toolCalls.get(id); - if (existing) { - existing.output = (p.output ?? undefined) as JsonValue | undefined; - existing.status = p.error ? 'error' : 'success'; - existing.error = p.error ?? undefined; - } else { - toolCalls.set(id, { - id, - name: p.toolName ?? 'unknown', - input: null as unknown as JsonValue, - output: (p.output ?? undefined) as JsonValue | undefined, - timestamp: new Date(event.timestamp).getTime(), - status: p.error ? 'error' : 'success', - error: p.error ?? undefined, - }); - } - } - } - - return Array.from(toolCalls.values()); - } -} diff --git a/packages/cli/src/context/ContextManager.ts b/packages/cli/src/context/ContextManager.ts index c03eb37e1..f9e5d6aac 100644 --- a/packages/cli/src/context/ContextManager.ts +++ b/packages/cli/src/context/ContextManager.ts @@ -24,7 +24,6 @@ import type { */ export class ContextManager { private readonly persistent: PersistentStore; - private readonly options: ContextManagerOptions; /** * 获取持久化存储实例(供外部直接调用 JSONL 操作) @@ -34,31 +33,11 @@ export class ContextManager { } constructor(options: Partial = {}) { - this.options = { - projectPath: options.projectPath || getCwd(), - ...(options.stateStorage ? { stateStorage: options.stateStorage } : {}), - storage: { - maxMemorySize: 1000, - persistentPath: '', - cacheSize: 100, - compressionEnabled: true, - ...options.storage, - }, - defaultFilter: { - maxTokens: 32000, - maxMessages: 50, - timeWindow: 24 * 60 * 60 * 1000, - ...options.defaultFilter, - }, - compressionThreshold: options.compressionThreshold || 6000, - enableVectorSearch: options.enableVectorSearch || false, - }; - this.persistent = new PersistentStore( - this.options.projectPath, + options.projectPath ?? getCwd(), 100, undefined, - this.options.stateStorage + options.stateStorage ); } diff --git a/packages/cli/src/context/storage/PersistentStore.ts b/packages/cli/src/context/storage/PersistentStore.ts index 88f35ea66..44dffbd48 100644 --- a/packages/cli/src/context/storage/PersistentStore.ts +++ b/packages/cli/src/context/storage/PersistentStore.ts @@ -23,14 +23,12 @@ import { type ValidTokenBudgetHandoffEvent, } from '../TokenBudgetHandoff.js'; import { - type ConversationContext, MAX_TURN_INPUT_MESSAGE_ID_CHARS, MAX_TURN_INPUT_MESSAGE_IDS, type MessageInfo, type MessagePersistenceMetadata, type PartInfo, parseTurnInputMessageIds, - type SessionContext, type SessionEvent, type SessionGoalFinalizationInfo, type SessionInfo, @@ -2138,77 +2136,6 @@ export class PersistentStore { } } - /** - * 加载会话上下文(从 JSONL 重建) - */ - async loadSession(sessionId: string): Promise { - try { - const entries = materializeSessionEvents(await this.log(sessionId).readAll()); - if (entries.length === 0) return null; - const firstEntry = entries.find((entry) => entry.type === 'session_created'); - - return { - sessionId, - userId: undefined, - preferences: {}, - configuration: {}, - startTime: new Date(firstEntry?.timestamp ?? entries[0].timestamp).getTime(), - }; - } catch { - return null; - } - } - - /** - * 加载对话上下文(从 JSONL 重建) - */ - async loadConversation(sessionId: string): Promise { - try { - const entries = materializeSessionEvents(await this.log(sessionId).readAll()); - if (entries.length === 0) return null; - const messageMap = new Map< - string, - { id: string; role: MessageRole; content: string; timestamp: number } - >(); - for (const entry of entries) { - if (entry.type === 'message_created') { - messageMap.set(entry.data.messageId, { - id: entry.data.messageId, - role: entry.data.role, - content: '', - timestamp: new Date(entry.timestamp).getTime(), - }); - } - if (entry.type === 'part_created' && entry.data.partType === 'text') { - const message = messageMap.get(entry.data.messageId); - if (message) { - const payload = entry.data.payload as { text?: string }; - message.content = payload.text ?? ''; - } - } - if (entry.type === 'part_created' && entry.data.partType === 'image') { - const message = messageMap.get(entry.data.messageId); - if (message) { - message.content = message.content - ? `${message.content}\n[Image]` - : '[Image]'; - } - } - } - const messages = Array.from(messageMap.values()); - const lastEntry = entries[entries.length - 1]; - const lastActivity = new Date(lastEntry.timestamp).getTime(); - - return { - messages, - topics: [], - lastActivity, - }; - } catch { - return null; - } - } - /** * 获取所有会话列表 */ diff --git a/packages/cli/src/context/types.ts b/packages/cli/src/context/types.ts index 85e2b8f9c..d7546025a 100644 --- a/packages/cli/src/context/types.ts +++ b/packages/cli/src/context/types.ts @@ -14,7 +14,6 @@ import type { SessionStateStorage } from './storage/SessionStateStorage.js'; export const MAX_TURN_INPUT_MESSAGE_IDS = 120; export const MAX_TURN_INPUT_MESSAGE_ID_CHARS = 128; -export const MAX_TURN_ID_CHARS = 128; export function parseTurnInputMessageIds(value: unknown): string[] | undefined { if ( @@ -75,113 +74,9 @@ export function parseTurnAbortAcknowledgedInputMessageIds(data: unknown): string ); } -export interface ContextMessage { - id: string; - role: MessageRole; - content: string; - timestamp: number; - metadata?: JsonObject; -} - -export interface ToolCall { - id: string; - name: string; - input: JsonValue; - output?: JsonValue; - timestamp: number; - status: 'pending' | 'success' | 'error'; - error?: string; -} - -export interface SystemContext { - role: string; - capabilities: string[]; - tools: string[]; - version: string; -} - -export interface SessionContext { - sessionId: string; - userId?: string; - preferences: JsonObject; - configuration: JsonObject; - startTime: number; -} - -export interface ConversationContext { - messages: ContextMessage[]; - summary?: string; - topics: string[]; - lastActivity: number; -} - -interface ToolContext { - recentCalls: ToolCall[]; - toolStates: JsonObject; - dependencies: Record; -} - -export interface WorkspaceContext { - projectPath?: string; - currentFiles: string[]; - recentFiles: string[]; - gitInfo?: { - branch: string; - status: string; - lastCommit?: string; - }; - environment: JsonObject; -} - -export interface ContextLayer { - system: SystemContext; - session: SessionContext; - conversation: ConversationContext; - tool: ToolContext; - workspace: WorkspaceContext; -} - -export interface ContextData { - layers: ContextLayer; - metadata: { - totalTokens: number; - priority: number; - relevanceScore?: number; - lastUpdated: number; - }; -} - -export interface ContextFilter { - maxTokens?: number; - maxMessages?: number; - timeWindow?: number; // 毫秒 - priority?: number; - includeTools?: boolean; - includeWorkspace?: boolean; -} - -export interface CompressedContext { - summary: string; - keyPoints: string[]; - recentMessages: ContextMessage[]; - toolSummary?: string; - tokenCount: number; -} - -export interface ContextStorageOptions { - maxMemorySize: number; - persistentPath?: string; - cacheSize: number; - compressionEnabled: boolean; -} - export interface ContextManagerOptions { projectPath: string; stateStorage?: SessionStateStorage; - storage: ContextStorageOptions; - defaultFilter: ContextFilter; - compressionThreshold: number; - enableVectorSearch?: boolean; } /** diff --git a/packages/cli/src/mcp/loadMcpConfig.ts b/packages/cli/src/mcp/loadMcpConfig.ts index 384b04361..1f467d96f 100644 --- a/packages/cli/src/mcp/loadMcpConfig.ts +++ b/packages/cli/src/mcp/loadMcpConfig.ts @@ -13,7 +13,6 @@ import path from 'path'; import { getOriginalCwd } from '../bootstrap/state.js'; import type { McpServerConfig } from '../config/types.js'; import { createLogger, LogCategory } from '../logging/Logger.js'; -import { getMcpServers, getState } from '../store/vanilla.js'; const logger = createLogger(LogCategory.GENERAL); @@ -76,17 +75,3 @@ export async function resolveMcpConfigFromCli( } return servers; } - -/** - * 从 CLI --mcp-config 参数加载 MCP 配置 - * 支持多种格式: - * - JSON 文件路径: "./mcp-config.json" - * - JSON 字符串 (单个服务器): '{"name": "xxx", "type": "stdio", "command": "xxx"}' - * - JSON 字符串 (多个服务器): '{"server1": {...}, "server2": {...}}' - * - * @param mcpConfigs - CLI 参数数组 - */ -export async function loadMcpConfigFromCli(mcpConfigs: string[]): Promise { - const updatedServers = await resolveMcpConfigFromCli(mcpConfigs, getMcpServers()); - getState().config.actions.updateConfig({ mcpServers: updatedServers }); -} diff --git a/packages/cli/src/memory/MemoryConsolidation.ts b/packages/cli/src/memory/MemoryConsolidation.ts index 106ab1d9d..9e37cf25e 100644 --- a/packages/cli/src/memory/MemoryConsolidation.ts +++ b/packages/cli/src/memory/MemoryConsolidation.ts @@ -189,21 +189,3 @@ export async function commitMemoryConsolidation( return { outcome: 'failed', entries: 0, topics: [] }; } } - -/** Compatibility helper for internal callers migrating to the plan API. */ -export function extractLearnings(messages: Message[]): Map { - const result = new Map(); - for (const entry of planMemoryConsolidation(messages).entries) { - const values = result.get(entry.topic) ?? []; - values.push(entry.content); - result.set(entry.topic, values); - } - return result; -} - -export async function consolidateAfterCompaction( - discardedMessages: Message[], - options: MemoryConsolidationCommitOptions -): Promise { - return commitMemoryConsolidation(planMemoryConsolidation(discardedMessages), options); -} diff --git a/packages/cli/src/plugins/PluginManifest.ts b/packages/cli/src/plugins/PluginManifest.ts index a01f84ce2..f0cf84914 100644 --- a/packages/cli/src/plugins/PluginManifest.ts +++ b/packages/cli/src/plugins/PluginManifest.ts @@ -99,21 +99,3 @@ export async function parsePluginManifest( // No manifest found in any directory return null; } - -/** - * Check if a directory is a valid plugin directory - * - * A valid plugin directory must contain a plugin.json in either - * .blade-plugin/ or .claude-plugin/ subdirectory. - * - * @param dirPath - Path to check - * @returns True if the directory is a valid plugin - */ -export async function isValidPluginDir(dirPath: string): Promise { - try { - const result = await parsePluginManifest(dirPath); - return result !== null; - } catch { - return false; - } -} diff --git a/packages/cli/src/plugins/PluginSourcePolicy.ts b/packages/cli/src/plugins/PluginSourcePolicy.ts index cc153c3be..b05fca7b4 100644 --- a/packages/cli/src/plugins/PluginSourcePolicy.ts +++ b/packages/cli/src/plugins/PluginSourcePolicy.ts @@ -133,7 +133,3 @@ export function assertPluginSourceAllowed( `Marketplace "${source.marketplace}"` ); } - -export function isFullPluginGitSha(value: string | undefined): boolean { - return value !== undefined && FULL_GIT_SHA_PATTERN.test(value); -} diff --git a/packages/cli/src/services/StructuredOutputService.ts b/packages/cli/src/services/StructuredOutputService.ts index 91f832557..f4cd408bc 100644 --- a/packages/cli/src/services/StructuredOutputService.ts +++ b/packages/cli/src/services/StructuredOutputService.ts @@ -1,9 +1,9 @@ import { createHash } from 'node:crypto'; import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'; import type { JSONSchema7 } from 'json-schema'; -import type { Message } from './ChatServiceInterface.js'; import type { JsonObject, JsonValue } from '../store/types.js'; import type { FunctionDeclaration } from '../tools/types/index.js'; +import type { Message } from './ChatServiceInterface.js'; export const STRUCTURED_OUTPUT_TOOL_NAME = 'StructuredOutput'; export const MAX_STRUCTURED_OUTPUT_SCHEMA_BYTES = 64 * 1024; @@ -185,15 +185,6 @@ export function createStructuredOutputContract( }; } -export function isStructuredOutputSchema(value: unknown): value is JsonObject { - try { - createStructuredOutputContract(value); - return true; - } catch { - return false; - } -} - function outputFromMetadata( value: unknown, contract: StructuredOutputContract diff --git a/packages/cli/src/services/modelAlias.ts b/packages/cli/src/services/modelAlias.ts index 50acafa7b..544cd4100 100644 --- a/packages/cli/src/services/modelAlias.ts +++ b/packages/cli/src/services/modelAlias.ts @@ -43,17 +43,3 @@ export function resolveModelAlias(nameOrAlias: string): string { const lower = nameOrAlias.toLowerCase().trim(); return MODEL_ALIASES[lower] ?? nameOrAlias; } - -/** - * Check if a string is a known alias. - */ -export function isModelAlias(name: string): boolean { - return name.toLowerCase().trim() in MODEL_ALIASES; -} - -/** - * Get all available aliases for display. - */ -export function getModelAliases(): Array<{ alias: string; model: string }> { - return Object.entries(MODEL_ALIASES).map(([alias, model]) => ({ alias, model })); -} diff --git a/packages/cli/src/services/pi/providerCircuitBreaker.ts b/packages/cli/src/services/pi/providerCircuitBreaker.ts index 612bb4a65..c5f314e09 100644 --- a/packages/cli/src/services/pi/providerCircuitBreaker.ts +++ b/packages/cli/src/services/pi/providerCircuitBreaker.ts @@ -16,11 +16,9 @@ export { export const PROVIDER_CIRCUIT_WINDOW_MS = 60_000; export const PROVIDER_CIRCUIT_MIN_SAMPLES = 4; export const PROVIDER_CIRCUIT_ERROR_RATE_THRESHOLD = 0.8; -export const PROVIDER_CIRCUIT_HALF_OPEN_MAX_PROBES = 1; export const MIN_PROVIDER_CIRCUIT_PROBE_LEASE_MS = 300_000; export const MAX_PROVIDER_CIRCUIT_PROBE_LEASE_MS = 600_000; -export const PROVIDER_CIRCUIT_HEARTBEAT_MS = 15_000; export const MAX_PROVIDER_CIRCUIT_REGISTRY_ENTRIES = 128; export const MAX_PROVIDER_CIRCUIT_WINDOW_ENTRIES = 256; diff --git a/packages/cli/src/skills/SkillRegistry.ts b/packages/cli/src/skills/SkillRegistry.ts index d47b0e36a..86154f84a 100644 --- a/packages/cli/src/skills/SkillRegistry.ts +++ b/packages/cli/src/skills/SkillRegistry.ts @@ -465,13 +465,3 @@ export class SkillRegistry { export function getSkillRegistry(config?: SkillRegistryConfig): SkillRegistry { return SkillRegistry.getInstance(config); } - -/** - * 初始化并获取所有 skills - */ -export async function discoverSkills( - config?: SkillRegistryConfig -): Promise { - const registry = getSkillRegistry(config); - return registry.initialize(); -} diff --git a/packages/cli/src/skills/index.ts b/packages/cli/src/skills/index.ts index 9fb0d8ede..29f942e3f 100644 --- a/packages/cli/src/skills/index.ts +++ b/packages/cli/src/skills/index.ts @@ -27,9 +27,4 @@ * ``` */ -export { injectSkillsMetadata } from './injectSkillsMetadata.js'; -export { - discoverSkills, - getSkillRegistry, - SkillRegistry, -} from './SkillRegistry.js'; +export { getSkillRegistry, SkillRegistry } from './SkillRegistry.js'; diff --git a/packages/cli/src/skills/injectSkillsMetadata.ts b/packages/cli/src/skills/injectSkillsMetadata.ts deleted file mode 100644 index 9e576e4a4..000000000 --- a/packages/cli/src/skills/injectSkillsMetadata.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Skills 元数据注入 - * - * 在工具函数声明中动态替换 占位符。 - * 使用 Progressive Disclosure:仅注入元数据(name + description),不注入完整内容。 - */ - -import type { FunctionDeclaration } from '../tools/types/index.js'; -import { getSkillRegistry } from './SkillRegistry.js'; - -/** Skill 工具名称 */ -const SKILL_TOOL_NAME = 'Skill'; - -/** available_skills 占位符的正则表达式 */ -const AVAILABLE_SKILLS_REGEX = /\s*<\/available_skills>/; - -/** - * 在工具函数声明列表中注入 Skills 元数据 - * - * 查找 Skill 工具的描述,将 占位符 - * 替换为已发现的 skills 列表。 - * - * @param tools - 工具函数声明列表 - * @returns 注入后的工具函数声明列表(新数组,不修改原数组) - */ -export function injectSkillsMetadata( - tools: FunctionDeclaration[], - workspaceRoot?: string -): FunctionDeclaration[] { - const registry = getSkillRegistry(workspaceRoot ? { cwd: workspaceRoot } : undefined); - const skillsList = registry.generateAvailableSkillsList(); - - // 如果没有发现任何 skills,返回原数组 - if (!skillsList) { - return tools; - } - - return tools.map((tool) => { - // 只处理 Skill 工具 - if (tool.name !== SKILL_TOOL_NAME) { - return tool; - } - - // 替换 available_skills 占位符 - const newDescription = tool.description.replace( - AVAILABLE_SKILLS_REGEX, - `\n${skillsList}\n` - ); - - // 如果描述没有变化,返回原对象 - if (newDescription === tool.description) { - return tool; - } - - // 返回新的工具声明对象 - return { - ...tool, - description: newDescription, - }; - }); -} diff --git a/packages/cli/src/store/selectors/index.ts b/packages/cli/src/store/selectors/index.ts index 57bb1501f..5eb2ba303 100644 --- a/packages/cli/src/store/selectors/index.ts +++ b/packages/cli/src/store/selectors/index.ts @@ -59,18 +59,6 @@ export const usePromptCacheHitRate = () => }).hitRate; }); -export const useProviderCircuit = () => - useBladeStore((state) => state.session.providerCircuit); - -export const useProviderAdmission = () => - useBladeStore((state) => state.session.providerAdmission); - -export const useProviderRetry = () => - useBladeStore((state) => state.session.providerRetry); - -export const useProviderStall = () => - useBladeStore((state) => state.session.providerStall); - export const useProviderRecovery = () => useBladeStore((state) => state.session.providerRecovery); diff --git a/packages/cli/src/store/vanilla.ts b/packages/cli/src/store/vanilla.ts index 2be02dd6c..9b5a67879 100644 --- a/packages/cli/src/store/vanilla.ts +++ b/packages/cli/src/store/vanilla.ts @@ -188,10 +188,6 @@ export const getMcpServers = () => getState().config.config?.mcpServers ?? {}; * 获取 Thinking 模式是否启用 */ export const getThinkingModeEnabled = () => getState().app.reasoningEffort !== 'off'; -export const getReasoningEffort = () => getState().app.reasoningEffort; -export const getServiceTier = () => getState().app.serviceTier; -export const getResponseVerbosity = () => getState().app.responseVerbosity; -export const getCommunicationStyle = () => getState().app.communicationStyle; // ==================== Config Actions(带持久化)==================== diff --git a/packages/cli/src/tools/builtin/system/index.ts b/packages/cli/src/tools/builtin/system/index.ts index aff831625..a24203040 100644 --- a/packages/cli/src/tools/builtin/system/index.ts +++ b/packages/cli/src/tools/builtin/system/index.ts @@ -2,6 +2,6 @@ export { askUserQuestionTool } from './askUserQuestion.js'; export { createReadPromptArtifactTool } from './readPromptArtifact.js'; -export { createSkillTool, skillTool } from './skill.js'; -export { createSlashCommandTool, slashCommandTool } from './slashCommand.js'; +export { createSkillTool } from './skill.js'; +export { createSlashCommandTool } from './slashCommand.js'; export { toolSearchTool } from './ToolSearchTool.js'; diff --git a/packages/cli/src/tools/builtin/system/skill.ts b/packages/cli/src/tools/builtin/system/skill.ts index 883910155..6f695d9df 100644 --- a/packages/cli/src/tools/builtin/system/skill.ts +++ b/packages/cli/src/tools/builtin/system/skill.ts @@ -119,9 +119,6 @@ ${availableSkills} }); } -/** @deprecated Use createSkillTool(registry). */ -export const skillTool = createSkillTool(); - /** * 构建完整的 Skill 指令 */ diff --git a/packages/cli/src/tools/builtin/system/slashCommand.ts b/packages/cli/src/tools/builtin/system/slashCommand.ts index 6061c09f6..e4364521a 100644 --- a/packages/cli/src/tools/builtin/system/slashCommand.ts +++ b/packages/cli/src/tools/builtin/system/slashCommand.ts @@ -220,9 +220,6 @@ ${generateAvailableCommandsDescription(registry)}`, }); } -/** @deprecated Use createSlashCommandTool(registry). */ -export const slashCommandTool = createSlashCommandTool(); - /** * 构建完整的命令指令 */ diff --git a/packages/cli/src/ui/components/model-config/types.ts b/packages/cli/src/ui/components/model-config/types.ts index 660144ba0..4b3f2ba9c 100644 --- a/packages/cli/src/ui/components/model-config/types.ts +++ b/packages/cli/src/ui/components/model-config/types.ts @@ -16,19 +16,6 @@ export interface ModelConfigWizardProps { onCancel: () => void; } -export const PROVIDER_ICONS: Record = { - anthropic: '', - openai: '', - google: '', - deepseek: '', - groq: '', - openrouter: '', - 'azure-openai-responses': '', - mistral: '', - xai: '', - default: '', -}; - export const PROVIDER_HEADERS: Record> = { openrouter: { 'HTTP-Referer': 'https://github.com/echoVic/blade-code', diff --git a/packages/cli/src/ui/utils/rawStreamRenderer.ts b/packages/cli/src/ui/utils/rawStreamRenderer.ts index b47a5bea9..193414611 100644 --- a/packages/cli/src/ui/utils/rawStreamRenderer.ts +++ b/packages/cli/src/ui/utils/rawStreamRenderer.ts @@ -273,10 +273,3 @@ export function clearRawRenderer(): void { export function isRawRendererActive(): boolean { return state.active; } - -/** - * 获取当前 raw 区域占用的行数 - */ -export function getRawRendererLineCount(): number { - return state.renderedLineCount; -} diff --git a/packages/cli/tests/unit/agent-runtime/agent/execution-engine.test.ts b/packages/cli/tests/unit/agent-runtime/agent/execution-engine.test.ts index eb527e157..7b72103cf 100644 --- a/packages/cli/tests/unit/agent-runtime/agent/execution-engine.test.ts +++ b/packages/cli/tests/unit/agent-runtime/agent/execution-engine.test.ts @@ -1,11 +1,6 @@ -/** - * ExecutionEngine 单元测试 - */ - import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ExecutionEngine } from '../../../../src/agent/ExecutionEngine.js'; -// Mock 服务 const mockChatService = { chat: vi.fn().mockResolvedValue({ content: 'Mock response', @@ -21,110 +16,37 @@ describe('ExecutionEngine', () => { let executionEngine: ExecutionEngine; beforeEach(() => { - // 重置所有 mock vi.clearAllMocks(); - - // 创建新的 ExecutionEngine 实例 executionEngine = new ExecutionEngine(mockChatService as any); }); - describe('初始化', () => { - it('应该成功创建 ExecutionEngine 实例', () => { - expect(executionEngine).toBeInstanceOf(ExecutionEngine); + it('executes a task with one user message', async () => { + const response = await executionEngine.executeTask({ + id: 'test-task', + type: 'simple', + prompt: 'Test message', }); - }); - describe('任务执行', () => { - it('应该能够执行任务', async () => { - const task = { - id: 'test-task', - type: 'simple' as const, - prompt: 'Test message', - }; - - const response = await executionEngine.executeTask(task); - - expect(response).toBeDefined(); - expect(response.taskId).toBe('test-task'); - expect(response.content).toBe('Mock response'); - expect(mockChatService.chat).toHaveBeenCalled(); - }); - - it('应该正确处理上下文消息', async () => { - const task = { - id: 'test-task', - type: 'simple' as const, - prompt: 'Test message', - }; - - await executionEngine.executeTask(task); - - expect(mockChatService.chat).toHaveBeenCalledWith([ - { role: 'user', content: 'Test message' }, - ]); + expect(response).toEqual({ + taskId: 'test-task', + content: 'Mock response', + metadata: { taskType: 'simple' }, }); + expect(mockChatService.chat).toHaveBeenCalledWith([ + { role: 'user', content: 'Test message' }, + ]); + expect(executionEngine.getContextManager()).toBeDefined(); }); - describe('错误处理', () => { - it('应该正确处理任务执行错误', async () => { - mockChatService.chat.mockRejectedValueOnce(new Error('Execution Error')); - - const task = { - id: 'test-task', - type: 'simple' as const, - prompt: 'Test message', - }; - - await expect(executionEngine.executeTask(task)).rejects.toThrow( - 'Execution Error' - ); - }); - - it('应该正确处理上下文构建错误', async () => { - mockChatService.chat.mockRejectedValueOnce(new Error('Context Error')); + it('propagates chat service errors', async () => { + mockChatService.chat.mockRejectedValueOnce(new Error('Execution Error')); - const task = { + await expect( + executionEngine.executeTask({ id: 'test-task', - type: 'simple' as const, + type: 'simple', prompt: 'Test message', - }; - - await expect(executionEngine.executeTask(task)).rejects.toThrow('Context Error'); - }); - }); - - describe('上下文管理', () => { - it('应该提供 ContextManager', () => { - const contextManager = executionEngine.getContextManager(); - expect(contextManager).toBeDefined(); - }); - - it('应该提供 MemoryAdapter', () => { - const memoryAdapter = executionEngine.getMemoryAdapter(); - expect(memoryAdapter).toBeDefined(); - expect(memoryAdapter.getMessages()).toEqual([]); - }); - - it('MemoryAdapter 应该能够添加和获取消息', () => { - const memoryAdapter = executionEngine.getMemoryAdapter(); - - memoryAdapter.addMessage({ role: 'user', content: 'Hello' }); - memoryAdapter.addMessage({ role: 'assistant', content: 'Hi there' }); - - const messages = memoryAdapter.getMessages(); - expect(messages).toHaveLength(2); - expect(messages[0].content).toBe('Hello'); - expect(messages[1].content).toBe('Hi there'); - }); - - it('MemoryAdapter 应该能够清空上下文', () => { - const memoryAdapter = executionEngine.getMemoryAdapter(); - - memoryAdapter.addMessage({ role: 'user', content: 'Hello' }); - expect(memoryAdapter.getContextSize()).toBe(1); - - memoryAdapter.clearContext(); - expect(memoryAdapter.getContextSize()).toBe(0); - }); + }) + ).rejects.toThrow('Execution Error'); }); }); diff --git a/packages/cli/tests/unit/agent-runtime/agent/session-runtime.test.ts b/packages/cli/tests/unit/agent-runtime/agent/session-runtime.test.ts index 4b9c3e04e..b967493ae 100644 --- a/packages/cli/tests/unit/agent-runtime/agent/session-runtime.test.ts +++ b/packages/cli/tests/unit/agent-runtime/agent/session-runtime.test.ts @@ -5407,15 +5407,6 @@ describe('SessionRuntime', () => { await runtime.dispose(); }); - it('keeps the deprecated execution pipeline factory source-compatible', () => { - const runtime = new SessionRuntime({ permissions: {} } as any, { - sessionId: 'session-1', - }); - - expect(runtime.createExecutionPipeline()).toBeInstanceOf(ToolExecutor); - expect(runtime.createToolExecutor()).toBeInstanceOf(ToolExecutor); - }); - it('publishes and clears its ephemeral Provider recovery projection', () => { const runtime = new SessionRuntime(DEFAULT_CONFIG, { sessionId: 'provider-recovery-runtime', diff --git a/packages/cli/tests/unit/agent-runtime/context/turn-lifecycle.test.ts b/packages/cli/tests/unit/agent-runtime/context/turn-lifecycle.test.ts index d2c02ad6d..eca40da53 100644 --- a/packages/cli/tests/unit/agent-runtime/context/turn-lifecycle.test.ts +++ b/packages/cli/tests/unit/agent-runtime/context/turn-lifecycle.test.ts @@ -911,7 +911,7 @@ describe('durable turn lifecycle', () => { await appendRawTurnAbort(workspaceRoot, sessionId, turnId, receipt); await expect(store.recoverInterruptedTurn(sessionId)).resolves.toBeUndefined(); - await expect(store.loadSession(sessionId)).resolves.toMatchObject({ sessionId }); + await expect(store.loadEvents(sessionId)).resolves.not.toBeNull(); } ); diff --git a/packages/cli/tests/unit/context/ContextAssembler.test.ts b/packages/cli/tests/unit/context/ContextAssembler.test.ts deleted file mode 100644 index 730a2bb26..000000000 --- a/packages/cli/tests/unit/context/ContextAssembler.test.ts +++ /dev/null @@ -1,456 +0,0 @@ -/** - * ContextAssembler 单元测试 - */ - -import { describe, expect, it } from 'vitest'; -import { ContextAssembler } from '../../../src/context/ContextAssembler.js'; -import type { SessionEvent } from '../../../src/context/types.js'; - -// Helper: 创建基础事件字段 -const base = ( - type: string, - sessionId = 'sess-1', - timestamp = '2026-02-21T10:00:00.000Z' -): Omit => ({ - id: `evt-${Math.random().toString(36).slice(2, 8)}`, - sessionId, - timestamp, - cwd: '/tmp/test', - version: '1.0.0', -}); - -function sessionCreatedEvent( - sessionId = 'sess-1', - ts = '2026-02-21T10:00:00.000Z' -): SessionEvent { - return { - ...base('session_created', sessionId, ts), - type: 'session_created', - data: { - sessionId, - rootId: sessionId, - createdAt: ts, - }, - } as SessionEvent; -} - -function messageCreatedEvent( - messageId: string, - role: 'user' | 'assistant' | 'system', - ts = '2026-02-21T10:01:00.000Z', - model?: string -): SessionEvent { - return { - ...base('message_created', 'sess-1', ts), - type: 'message_created', - data: { messageId, role, createdAt: ts, model }, - } as SessionEvent; -} - -function textPartEvent( - messageId: string, - text: string, - ts = '2026-02-21T10:01:01.000Z' -): SessionEvent { - return { - ...base('part_created', 'sess-1', ts), - type: 'part_created', - data: { - partId: `part-${Math.random().toString(36).slice(2, 8)}`, - messageId, - partType: 'text', - payload: { text }, - createdAt: ts, - }, - } as SessionEvent; -} - -function summaryPartEvent( - messageId: string, - text: string, - ts = '2026-02-21T10:05:00.000Z' -): SessionEvent { - return { - ...base('part_created', 'sess-1', ts), - type: 'part_created', - data: { - partId: `part-${Math.random().toString(36).slice(2, 8)}`, - messageId, - partType: 'summary', - payload: { text, metadata: { trigger: 'auto', preTokens: 5000 } }, - createdAt: ts, - }, - } as SessionEvent; -} - -function toolCallPartEvent( - messageId: string, - toolCallId: string, - toolName: string, - input: unknown, - ts = '2026-02-21T10:02:00.000Z' -): SessionEvent { - return { - ...base('part_created', 'sess-1', ts), - type: 'part_created', - data: { - partId: toolCallId, - messageId, - partType: 'tool_call', - payload: { toolCallId, toolName, input }, - createdAt: ts, - }, - } as SessionEvent; -} - -function toolResultPartEvent( - messageId: string, - toolCallId: string, - toolName: string, - output: unknown, - error?: string, - ts = '2026-02-21T10:02:30.000Z' -): SessionEvent { - return { - ...base('part_created', 'sess-1', ts), - type: 'part_created', - data: { - partId: toolCallId, - messageId, - partType: 'tool_result', - payload: { toolCallId, toolName, output, error: error ?? null }, - createdAt: ts, - }, - } as SessionEvent; -} - -function partUpdatedEvent( - messageId: string, - text: string, - ts = '2026-02-21T10:03:00.000Z' -): SessionEvent { - return { - ...base('part_updated', 'sess-1', ts), - type: 'part_updated', - data: { - partId: `part-upd`, - messageId, - partType: 'text', - payload: { text }, - createdAt: ts, - }, - } as SessionEvent; -} - -function sessionUpdatedEvent( - data: Record, - ts = '2026-02-21T10:04:00.000Z' -): SessionEvent { - return { - ...base('session_updated', 'sess-1', ts), - type: 'session_updated', - data, - } as SessionEvent; -} - -describe('ContextAssembler', () => { - const assembler = new ContextAssembler(); - - describe('assemble', () => { - it('should return null for empty events', () => { - expect(assembler.assemble([])).toBeNull(); - }); - - it('should assemble a basic session with one message', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'user'), - textPartEvent('msg-1', 'Hello world'), - ]; - - const result = assembler.assemble(events)!; - expect(result).not.toBeNull(); - expect(result.session.sessionId).toBe('sess-1'); - expect(result.conversation.messages).toHaveLength(1); - expect(result.conversation.messages[0].content).toBe('Hello world'); - expect(result.conversation.messages[0].role).toBe('user'); - expect(result.toolCalls).toHaveLength(0); - }); - - it('should assemble multiple messages in order', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'user', '2026-02-21T10:01:00.000Z'), - textPartEvent('msg-1', 'What is 1+1?', '2026-02-21T10:01:01.000Z'), - messageCreatedEvent('msg-2', 'assistant', '2026-02-21T10:01:02.000Z'), - textPartEvent('msg-2', '1+1 = 2', '2026-02-21T10:01:03.000Z'), - ]; - - const result = assembler.assemble(events)!; - expect(result.conversation.messages).toHaveLength(2); - expect(result.conversation.messages[0].content).toBe('What is 1+1?'); - expect(result.conversation.messages[1].content).toBe('1+1 = 2'); - }); - - it('should preserve model metadata on messages', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent( - 'msg-1', - 'assistant', - '2026-02-21T10:01:00.000Z', - 'claude-3-opus' - ), - textPartEvent('msg-1', 'Hello'), - ]; - - const result = assembler.assemble(events)!; - expect(result.conversation.messages[0].metadata).toEqual({ - model: 'claude-3-opus', - }); - }); - - it('should not add metadata when model is undefined', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'user'), - textPartEvent('msg-1', 'Hello'), - ]; - - const result = assembler.assemble(events)!; - expect(result.conversation.messages[0].metadata).toBeUndefined(); - }); - }); - - describe('assembleSession', () => { - it('should extract sessionId and startTime from session_created', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent('my-session', '2026-02-21T08:00:00.000Z'), - ]; - - const result = assembler.assemble(events)!; - expect(result.session.sessionId).toBe('my-session'); - expect(result.session.startTime).toBe( - new Date('2026-02-21T08:00:00.000Z').getTime() - ); - }); - - it('should fallback to first event when no session_created', () => { - const events: SessionEvent[] = [ - messageCreatedEvent('msg-1', 'user', '2026-02-21T09:00:00.000Z'), - textPartEvent('msg-1', 'Hello'), - ]; - - const result = assembler.assemble(events)!; - expect(result.session.sessionId).toBe('sess-1'); - }); - - it('should pick latest session_updated for configuration', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - sessionUpdatedEvent({ model: 'gpt-4' }, '2026-02-21T10:01:00.000Z'), - sessionUpdatedEvent({ model: 'claude-3' }, '2026-02-21T10:02:00.000Z'), - ]; - - const result = assembler.assemble(events)!; - expect(result.session.configuration).toEqual({ model: 'claude-3' }); - }); - - it('should have empty configuration when no session_updated', () => { - const events: SessionEvent[] = [sessionCreatedEvent()]; - - const result = assembler.assemble(events)!; - expect(result.session.configuration).toEqual({}); - }); - }); - - describe('assembleConversation - summary', () => { - it('should extract compaction summary', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-s', 'system', '2026-02-21T10:05:00.000Z'), - summaryPartEvent('msg-s', 'This is a summary of the conversation.'), - ]; - - const result = assembler.assemble(events)!; - expect(result.conversation.summary).toBe( - 'This is a summary of the conversation.' - ); - }); - - it('should use latest summary when multiple exist', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-s1', 'system', '2026-02-21T10:05:00.000Z'), - summaryPartEvent('msg-s1', 'First summary', '2026-02-21T10:05:00.000Z'), - messageCreatedEvent('msg-s2', 'system', '2026-02-21T10:10:00.000Z'), - summaryPartEvent('msg-s2', 'Second summary', '2026-02-21T10:10:00.000Z'), - ]; - - const result = assembler.assemble(events)!; - expect(result.conversation.summary).toBe('Second summary'); - }); - - it('should have undefined summary when no summary parts', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'user'), - textPartEvent('msg-1', 'Hello'), - ]; - - const result = assembler.assemble(events)!; - expect(result.conversation.summary).toBeUndefined(); - }); - }); - - describe('assembleConversation - part_updated', () => { - it('should overwrite message content on part_updated', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'assistant'), - textPartEvent('msg-1', 'Original text'), - partUpdatedEvent('msg-1', 'Updated text'), - ]; - - const result = assembler.assemble(events)!; - expect(result.conversation.messages[0].content).toBe('Updated text'); - }); - }); - - describe('assembleToolCalls', () => { - it('should assemble tool call with pending status', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'assistant'), - toolCallPartEvent('msg-1', 'tc-1', 'ReadFile', { path: '/tmp/test.ts' }), - ]; - - const result = assembler.assemble(events)!; - expect(result.toolCalls).toHaveLength(1); - expect(result.toolCalls[0].name).toBe('ReadFile'); - expect(result.toolCalls[0].status).toBe('pending'); - expect(result.toolCalls[0].input).toEqual({ path: '/tmp/test.ts' }); - }); - - it('should match tool result to existing tool call', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'assistant'), - toolCallPartEvent('msg-1', 'tc-1', 'ReadFile', { path: '/tmp/test.ts' }), - toolResultPartEvent('msg-1', 'tc-1', 'ReadFile', { content: 'file content' }), - ]; - - const result = assembler.assemble(events)!; - expect(result.toolCalls).toHaveLength(1); - expect(result.toolCalls[0].status).toBe('success'); - expect(result.toolCalls[0].output).toEqual({ content: 'file content' }); - }); - - it('should mark tool call as error when error present', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'assistant'), - toolCallPartEvent('msg-1', 'tc-1', 'ReadFile', { path: '/nonexistent' }), - toolResultPartEvent('msg-1', 'tc-1', 'ReadFile', null, 'File not found'), - ]; - - const result = assembler.assemble(events)!; - expect(result.toolCalls).toHaveLength(1); - expect(result.toolCalls[0].status).toBe('error'); - expect(result.toolCalls[0].error).toBe('File not found'); - }); - - it('should handle orphaned tool result (no matching call)', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'assistant'), - toolResultPartEvent('msg-1', 'tc-orphan', 'WriteFile', { written: true }), - ]; - - const result = assembler.assemble(events)!; - expect(result.toolCalls).toHaveLength(1); - expect(result.toolCalls[0].id).toBe('tc-orphan'); - expect(result.toolCalls[0].name).toBe('WriteFile'); - expect(result.toolCalls[0].status).toBe('success'); - }); - - it('should assemble multiple tool calls', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'assistant'), - toolCallPartEvent( - 'msg-1', - 'tc-1', - 'ReadFile', - { path: 'a.ts' }, - '2026-02-21T10:02:00.000Z' - ), - toolCallPartEvent( - 'msg-1', - 'tc-2', - 'ReadFile', - { path: 'b.ts' }, - '2026-02-21T10:02:01.000Z' - ), - toolResultPartEvent( - 'msg-1', - 'tc-1', - 'ReadFile', - 'content-a', - undefined, - '2026-02-21T10:02:02.000Z' - ), - toolResultPartEvent( - 'msg-1', - 'tc-2', - 'ReadFile', - 'content-b', - undefined, - '2026-02-21T10:02:03.000Z' - ), - ]; - - const result = assembler.assemble(events)!; - expect(result.toolCalls).toHaveLength(2); - expect(result.toolCalls[0].status).toBe('success'); - expect(result.toolCalls[1].status).toBe('success'); - }); - }); - - describe('assembleContextData', () => { - const mockSystem = { platform: 'test', version: '1.0' } as any; - const mockWorkspace = { rootPath: '/tmp', files: [] } as any; - - it('should return null for empty events', () => { - expect(assembler.assembleContextData([], mockSystem, mockWorkspace)).toBeNull(); - }); - - it('should build complete ContextData with all layers', () => { - const events: SessionEvent[] = [ - sessionCreatedEvent(), - messageCreatedEvent('msg-1', 'user'), - textPartEvent('msg-1', 'Hello'), - messageCreatedEvent('msg-2', 'assistant'), - toolCallPartEvent('msg-2', 'tc-1', 'Bash', { command: 'ls' }), - toolResultPartEvent('msg-2', 'tc-1', 'Bash', 'file1.ts\nfile2.ts'), - textPartEvent('msg-2', 'Here are the files.'), - ]; - - const result = assembler.assembleContextData(events, mockSystem, mockWorkspace)!; - expect(result).not.toBeNull(); - - // layers 完整 - expect(result.layers.system).toBe(mockSystem); - expect(result.layers.workspace).toBe(mockWorkspace); - expect(result.layers.session.sessionId).toBe('sess-1'); - expect(result.layers.conversation.messages).toHaveLength(2); - expect(result.layers.tool.recentCalls).toHaveLength(1); - expect(result.layers.tool.recentCalls[0].name).toBe('Bash'); - expect(result.layers.tool.recentCalls[0].status).toBe('success'); - - // metadata - expect(result.metadata.totalTokens).toBe(0); - expect(result.metadata.priority).toBe(1); - }); - }); -}); diff --git a/packages/cli/tests/unit/services/session-surface-service.test.ts b/packages/cli/tests/unit/services/session-surface-service.test.ts index 33eb5a7db..e3a57fc12 100644 --- a/packages/cli/tests/unit/services/session-surface-service.test.ts +++ b/packages/cli/tests/unit/services/session-surface-service.test.ts @@ -459,8 +459,8 @@ describe('SessionSurfaceService', () => { const hookGet = vi.spyOn(HookManager, 'getInstance'); const pluginGet = vi.spyOn(PluginRegistry, 'getInstance'); const skillGet = vi.spyOn(SkillRegistry, 'getInstance'); - const fileSystemGet = vi.spyOn(AcpServiceContext.prototype, 'getFileSystemService'); - const terminalGet = vi.spyOn(AcpServiceContext.prototype, 'getTerminalService'); + const fileSystemGet = vi.spyOn(AcpServiceContext, 'getFileSystemService'); + const terminalGet = vi.spyOn(AcpServiceContext, 'getTerminalService'); const localRead = vi.spyOn(LocalFileSystemService.prototype, 'readTextFile'); const gitBranch = vi.spyOn(storagePaths, 'detectGitBranch'); From d5373627ac2c95af7c729fc2f912a0f3920cf79a Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 12:53:18 +0800 Subject: [PATCH 05/59] refactor(session): unify metadata update event construction --- packages/cli/src/services/SessionService.ts | 414 ++++++++------------ 1 file changed, 155 insertions(+), 259 deletions(-) diff --git a/packages/cli/src/services/SessionService.ts b/packages/cli/src/services/SessionService.ts index fc3a6424c..b13a073ce 100644 --- a/packages/cli/src/services/SessionService.ts +++ b/packages/cli/src/services/SessionService.ts @@ -3,93 +3,93 @@ * 负责加载和恢复历史会话 */ +import { nanoid } from 'nanoid'; import type { BigIntStats } from 'node:fs'; import { readdir, readFile, rm, stat } from 'node:fs/promises'; import * as path from 'node:path'; -import { nanoid } from 'nanoid'; import { - type AcpRemoteStateScope, - assertAcpRemoteSessionTranscriptIdentity, - assertAcpRemoteStateFile, - assertAcpRemoteStateFileHandle, - deriveAcpRemoteHostStateRoot, - ensureAcpRemoteHostStateRoot, - listValidatedAcpRemoteStateScopes, - parseAcpRemoteWorkspaceDescriptor, - withValidatedAcpRemoteStateScope, + type AcpRemoteStateScope, + assertAcpRemoteSessionTranscriptIdentity, + assertAcpRemoteStateFile, + assertAcpRemoteStateFileHandle, + deriveAcpRemoteHostStateRoot, + ensureAcpRemoteHostStateRoot, + listValidatedAcpRemoteStateScopes, + parseAcpRemoteWorkspaceDescriptor, + withValidatedAcpRemoteStateScope, } from '../acp/AcpRemoteWorkspace.js'; import { getOrCreateAcpRemoteWorkspaceReferenceInScope } from '../acp/AcpRemoteWorkspaceReference.js'; import { SessionInUseError, SessionLease } from '../agent/runtime/SessionLease.js'; import { - collectUserPromptArtifactIds, - UserPromptArtifactStore, + collectUserPromptArtifactIds, + UserPromptArtifactStore, } from '../agent/runtime/UserPromptArtifactStore.js'; import { - MAX_INLINE_ATTACHMENT_BYTES, - MAX_INLINE_ATTACHMENT_COUNT, + MAX_INLINE_ATTACHMENT_BYTES, + MAX_INLINE_ATTACHMENT_COUNT, } from '../api/attachmentLimits.js'; import { removeBrowserSessionArtifacts } from '../browser/BrowserArtifactStore.js'; import type { - CommunicationStyleSelection, - ReasoningEffortSelection, - ResponseVerbositySelection, - ServiceTierSelection, + CommunicationStyleSelection, + ReasoningEffortSelection, + ResponseVerbositySelection, + ServiceTierSelection, } from '../config/types.js'; import { parseCompactionReplacementMessages } from '../context/compactionCheckpoint.js'; import { - findPendingSessionInteraction, - toPendingInteraction, + findPendingSessionInteraction, + toPendingInteraction, } from '../context/interactions.js'; import { - codeReviewMessageMetadata, - projectSessionReviews, - renderCodeReview, - renderReviewStatus, + codeReviewMessageMetadata, + projectSessionReviews, + renderCodeReview, + renderReviewStatus, } from '../context/reviews.js'; import { JSONLStore, parseSessionJSONL } from '../context/storage/JSONLStore.js'; import { - assertValidSessionId, - detectGitBranch, - getAcpRemoteSessionFilePath, - getBladeStorageRoot, - getProjectStoragePath, - getSessionFilePath, - getSessionGoalFilePath, - isValidSessionId, - normalizeLocalWorkspacePath, - unescapeProjectPath, + assertValidSessionId, + detectGitBranch, + getAcpRemoteSessionFilePath, + getBladeStorageRoot, + getProjectStoragePath, + getSessionFilePath, + getSessionGoalFilePath, + isValidSessionId, + normalizeLocalWorkspacePath, + unescapeProjectPath, } from '../context/storage/pathUtils.js'; import { - getProjectionDb, - type MetadataDeriver, - projectSessionSurfaceSummaryFields, - removeSessionFromProjection, - syncAcpRemoteScope, - syncAll, - syncAllAcpRemoteScopes, + getProjectionDb, + type MetadataDeriver, + projectSessionSurfaceSummaryFields, + removeSessionFromProjection, + syncAcpRemoteScope, + syncAll, + syncAllAcpRemoteScopes, } from '../context/storage/sqlite/projection.js'; +import { isSessionTaskFailure, toTaskFailure } from '../context/taskFailure.js'; import { - findCurrentTokenBudgetHandoff, - projectTokenBudgetHandoffEvent, + findCurrentTokenBudgetHandoff, + projectTokenBudgetHandoffEvent, } from '../context/TokenBudgetHandoff.js'; -import { isSessionTaskFailure, toTaskFailure } from '../context/taskFailure.js'; import type { - AcpRemoteWorkspaceDescriptorV1, - SessionEvent, - SessionPendingInteraction, - SessionPermissionMode, - SessionReviewTargetInfo, - SessionRewindMode, - SessionTaskDelivery, - SessionTaskDiffStat, - SessionTaskDispatch, - SessionTaskFailure, - SessionTaskIsolation, - SessionTaskKind, - SessionTaskPriority, - SessionTaskRetryRef, - SessionTaskStatus, - SessionTaskWorktree, + AcpRemoteWorkspaceDescriptorV1, + SessionEvent, + SessionPendingInteraction, + SessionPermissionMode, + SessionReviewTargetInfo, + SessionRewindMode, + SessionTaskDelivery, + SessionTaskDiffStat, + SessionTaskDispatch, + SessionTaskFailure, + SessionTaskIsolation, + SessionTaskKind, + SessionTaskPriority, + SessionTaskRetryRef, + SessionTaskStatus, + SessionTaskWorktree, } from '../context/types.js'; import { createLogger, LogCategory } from '../logging/Logger.js'; import type { JsonObject, JsonValue, SessionMessage } from '../store/types.js'; @@ -104,40 +104,40 @@ import { isReasoningEffortSelection } from './pi/reasoningEffort.js'; import { isResponseVerbositySelection } from './pi/responseVerbosity.js'; import { isServiceTierSelection } from './pi/serviceTier.js'; import { - renderSessionMarkdown, - type SessionMarkdownExport, - type SessionMarkdownExportOptions, -} from './SessionMarkdownExporter.js'; -import { createStructuredOutputContract } from './StructuredOutputService.js'; -import { - compareRemoteSessionCatalogItems, - compareSessionCatalogItems, - type NormalizedRemoteSessionListOptions, - type NormalizedSessionListOptions, - type NormalizedSessionTaskFilters, - normalizeRemoteSessionListOptions, - normalizeSessionListOptions, - normalizeSessionTaskFilters, - paginateRemoteSessionCatalog, - paginateSessionCatalog, - type RemoteSessionCatalogItem, - type RemoteSessionListOptions, - type RemoteSessionScanOptions, - resolveRemoteSessionCursorBoundary, - resolveSessionCursorBoundary, - type SessionListOptions, - type SessionScanOptions, - sessionCatalogSortKey, + compareRemoteSessionCatalogItems, + compareSessionCatalogItems, + type NormalizedRemoteSessionListOptions, + type NormalizedSessionListOptions, + type NormalizedSessionTaskFilters, + normalizeRemoteSessionListOptions, + normalizeSessionListOptions, + normalizeSessionTaskFilters, + paginateRemoteSessionCatalog, + paginateSessionCatalog, + type RemoteSessionCatalogItem, + type RemoteSessionListOptions, + type RemoteSessionScanOptions, + resolveRemoteSessionCursorBoundary, + resolveSessionCursorBoundary, + sessionCatalogSortKey, + type SessionListOptions, + type SessionScanOptions, } from './sessionCatalog.js'; import { - listSessionRewindCheckpoints as listProjectedRewindCheckpoints, - materializeSessionEvents, - type SessionRewindCheckpoint as ProjectedRewindCheckpoint, - planSessionRewind, + renderSessionMarkdown, + type SessionMarkdownExport, + type SessionMarkdownExportOptions, +} from './SessionMarkdownExporter.js'; +import { + listSessionRewindCheckpoints as listProjectedRewindCheckpoints, + materializeSessionEvents, + planSessionRewind, + type SessionRewindCheckpoint as ProjectedRewindCheckpoint, } from './sessionRewind.js'; +import { createStructuredOutputContract } from './StructuredOutputService.js'; import { - renderUserShellCommandForDisplay, - userShellCommandRecordFromMetadata, + renderUserShellCommandForDisplay, + userShellCommandRecordFromMetadata, } from './UserShellCommandService.js'; const logger = createLogger(LogCategory.SERVICE); @@ -621,6 +621,69 @@ export type RemoteSessionMetadataUpdate = Pick< | 'projectInstructionsDigest' >; +type SessionUpdatedData = Extract['data']; + +const SHARED_METADATA_UPDATE_KEYS = [ + 'title', + 'taskStatus', + 'taskStatusReason', + 'taskFailure', + 'taskStartedAt', + 'taskCompletedAt', + 'taskOwnerPid', + 'taskPromptSummary', + 'taskPriority', + 'taskKind', + 'taskDueAt', + 'taskModelId', + 'taskQueuePosition', + 'taskQueueDepth', + 'taskConcurrencyLimit', + 'selectedModelId', + 'permissionMode', + 'reasoningEffort', + 'serviceTier', + 'responseVerbosity', + 'communicationStyle', + 'communicationStyleDigest', + 'projectInstructionsDigest', +] as const satisfies readonly (keyof SessionMetadataUpdate)[]; + +const LOCAL_METADATA_UPDATE_KEYS = [ + 'taskDispatch', + 'taskRetriedFrom', + 'taskDelivery', + 'taskIsolation', + 'taskSourceProjectPath', + 'taskWorktree', + 'taskDiffStat', +] as const satisfies readonly (keyof SessionMetadataUpdate)[]; + +function buildSessionUpdatedData( + sessionId: string, + update: SessionMetadataUpdate, + updatedAt: string, + includeLocalFields: boolean +): SessionUpdatedData { + const data: SessionUpdatedData = { sessionId, updatedAt }; + const keys: readonly (keyof SessionMetadataUpdate)[] = includeLocalFields + ? [...SHARED_METADATA_UPDATE_KEYS, ...LOCAL_METADATA_UPDATE_KEYS] + : SHARED_METADATA_UPDATE_KEYS; + for (const key of keys) { + const value = update[key]; + if (value !== undefined) { + Reflect.set( + data, + key, + key === 'taskDueAt' && value !== null + ? new Date(String(value)).toISOString() + : value + ); + } + } + return data; +} + export interface SessionPage { sessions: SessionMetadata[]; nextCursor?: string; @@ -3305,101 +3368,7 @@ export class SessionService { cwd: resolvedProjectPath, gitBranch: detectGitBranch(resolvedProjectPath), version: getVersion(), - data: { - sessionId, - ...(update.title !== undefined ? { title: update.title } : {}), - ...(update.taskStatus !== undefined - ? { taskStatus: update.taskStatus } - : {}), - ...(update.taskStatusReason !== undefined - ? { taskStatusReason: update.taskStatusReason } - : {}), - ...(update.taskFailure !== undefined - ? { taskFailure: update.taskFailure } - : {}), - ...(update.taskStartedAt !== undefined - ? { taskStartedAt: update.taskStartedAt } - : {}), - ...(update.taskCompletedAt !== undefined - ? { taskCompletedAt: update.taskCompletedAt } - : {}), - ...(update.taskOwnerPid !== undefined - ? { taskOwnerPid: update.taskOwnerPid } - : {}), - ...(update.taskPromptSummary !== undefined - ? { taskPromptSummary: update.taskPromptSummary } - : {}), - ...(update.taskPriority !== undefined - ? { taskPriority: update.taskPriority } - : {}), - ...(update.taskKind !== undefined ? { taskKind: update.taskKind } : {}), - ...(update.taskDueAt !== undefined - ? { - taskDueAt: - update.taskDueAt === null - ? null - : new Date(update.taskDueAt).toISOString(), - } - : {}), - ...(update.taskDispatch !== undefined - ? { taskDispatch: update.taskDispatch } - : {}), - ...(update.taskModelId !== undefined - ? { taskModelId: update.taskModelId } - : {}), - ...(update.taskRetriedFrom !== undefined - ? { taskRetriedFrom: update.taskRetriedFrom } - : {}), - ...(update.taskDelivery !== undefined - ? { taskDelivery: update.taskDelivery } - : {}), - ...(update.taskIsolation !== undefined - ? { taskIsolation: update.taskIsolation } - : {}), - ...(update.taskSourceProjectPath !== undefined - ? { taskSourceProjectPath: update.taskSourceProjectPath } - : {}), - ...(update.taskWorktree !== undefined - ? { taskWorktree: update.taskWorktree } - : {}), - ...(update.taskDiffStat !== undefined - ? { taskDiffStat: update.taskDiffStat } - : {}), - ...(update.taskQueuePosition !== undefined - ? { taskQueuePosition: update.taskQueuePosition } - : {}), - ...(update.taskQueueDepth !== undefined - ? { taskQueueDepth: update.taskQueueDepth } - : {}), - ...(update.taskConcurrencyLimit !== undefined - ? { taskConcurrencyLimit: update.taskConcurrencyLimit } - : {}), - ...(update.selectedModelId !== undefined - ? { selectedModelId: update.selectedModelId } - : {}), - ...(update.permissionMode !== undefined - ? { permissionMode: update.permissionMode } - : {}), - ...(update.reasoningEffort !== undefined - ? { reasoningEffort: update.reasoningEffort } - : {}), - ...(update.serviceTier !== undefined - ? { serviceTier: update.serviceTier } - : {}), - ...(update.responseVerbosity !== undefined - ? { responseVerbosity: update.responseVerbosity } - : {}), - ...(update.communicationStyle !== undefined - ? { communicationStyle: update.communicationStyle } - : {}), - ...(update.communicationStyleDigest !== undefined - ? { communicationStyleDigest: update.communicationStyleDigest } - : {}), - ...(update.projectInstructionsDigest !== undefined - ? { projectInstructionsDigest: update.projectInstructionsDigest } - : {}), - updatedAt: now, - }, + data: buildSessionUpdatedData(sessionId, update, now, true), }; persistedEntries = [...entries, next]; return next; @@ -3491,80 +3460,7 @@ export class SessionService { type: 'session_updated', cwd: hostStateRoot, version: getVersion(), - data: { - sessionId, - ...(update.title !== undefined ? { title: update.title } : {}), - ...(update.taskStatus !== undefined - ? { taskStatus: update.taskStatus } - : {}), - ...(update.taskStatusReason !== undefined - ? { taskStatusReason: update.taskStatusReason } - : {}), - ...(update.taskFailure !== undefined - ? { taskFailure: update.taskFailure } - : {}), - ...(update.taskStartedAt !== undefined - ? { taskStartedAt: update.taskStartedAt } - : {}), - ...(update.taskCompletedAt !== undefined - ? { taskCompletedAt: update.taskCompletedAt } - : {}), - ...(update.taskOwnerPid !== undefined - ? { taskOwnerPid: update.taskOwnerPid } - : {}), - ...(update.taskPromptSummary !== undefined - ? { taskPromptSummary: update.taskPromptSummary } - : {}), - ...(update.taskPriority !== undefined - ? { taskPriority: update.taskPriority } - : {}), - ...(update.taskKind !== undefined ? { taskKind: update.taskKind } : {}), - ...(update.taskDueAt !== undefined - ? { - taskDueAt: - update.taskDueAt === null - ? null - : new Date(update.taskDueAt).toISOString(), - } - : {}), - ...(update.taskModelId !== undefined - ? { taskModelId: update.taskModelId } - : {}), - ...(update.taskQueuePosition !== undefined - ? { taskQueuePosition: update.taskQueuePosition } - : {}), - ...(update.taskQueueDepth !== undefined - ? { taskQueueDepth: update.taskQueueDepth } - : {}), - ...(update.taskConcurrencyLimit !== undefined - ? { taskConcurrencyLimit: update.taskConcurrencyLimit } - : {}), - ...(update.selectedModelId !== undefined - ? { selectedModelId: update.selectedModelId } - : {}), - ...(update.permissionMode !== undefined - ? { permissionMode: update.permissionMode } - : {}), - ...(update.reasoningEffort !== undefined - ? { reasoningEffort: update.reasoningEffort } - : {}), - ...(update.serviceTier !== undefined - ? { serviceTier: update.serviceTier } - : {}), - ...(update.responseVerbosity !== undefined - ? { responseVerbosity: update.responseVerbosity } - : {}), - ...(update.communicationStyle !== undefined - ? { communicationStyle: update.communicationStyle } - : {}), - ...(update.communicationStyleDigest !== undefined - ? { communicationStyleDigest: update.communicationStyleDigest } - : {}), - ...(update.projectInstructionsDigest !== undefined - ? { projectInstructionsDigest: update.projectInstructionsDigest } - : {}), - updatedAt: now, - }, + data: buildSessionUpdatedData(sessionId, update, now, false), }; persistedEntries = [...entries, next]; return next; From e70074724b30fb483bc626d1246d27bd76943fee Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:01:27 +0800 Subject: [PATCH 06/59] refactor(session): share local and remote fork projection --- packages/cli/src/services/SessionService.ts | 404 +++++++------------- 1 file changed, 141 insertions(+), 263 deletions(-) diff --git a/packages/cli/src/services/SessionService.ts b/packages/cli/src/services/SessionService.ts index b13a073ce..2dbccbdee 100644 --- a/packages/cli/src/services/SessionService.ts +++ b/packages/cli/src/services/SessionService.ts @@ -3,93 +3,93 @@ * 负责加载和恢复历史会话 */ -import { nanoid } from 'nanoid'; import type { BigIntStats } from 'node:fs'; import { readdir, readFile, rm, stat } from 'node:fs/promises'; import * as path from 'node:path'; +import { nanoid } from 'nanoid'; import { - type AcpRemoteStateScope, - assertAcpRemoteSessionTranscriptIdentity, - assertAcpRemoteStateFile, - assertAcpRemoteStateFileHandle, - deriveAcpRemoteHostStateRoot, - ensureAcpRemoteHostStateRoot, - listValidatedAcpRemoteStateScopes, - parseAcpRemoteWorkspaceDescriptor, - withValidatedAcpRemoteStateScope, + type AcpRemoteStateScope, + assertAcpRemoteSessionTranscriptIdentity, + assertAcpRemoteStateFile, + assertAcpRemoteStateFileHandle, + deriveAcpRemoteHostStateRoot, + ensureAcpRemoteHostStateRoot, + listValidatedAcpRemoteStateScopes, + parseAcpRemoteWorkspaceDescriptor, + withValidatedAcpRemoteStateScope, } from '../acp/AcpRemoteWorkspace.js'; import { getOrCreateAcpRemoteWorkspaceReferenceInScope } from '../acp/AcpRemoteWorkspaceReference.js'; import { SessionInUseError, SessionLease } from '../agent/runtime/SessionLease.js'; import { - collectUserPromptArtifactIds, - UserPromptArtifactStore, + collectUserPromptArtifactIds, + UserPromptArtifactStore, } from '../agent/runtime/UserPromptArtifactStore.js'; import { - MAX_INLINE_ATTACHMENT_BYTES, - MAX_INLINE_ATTACHMENT_COUNT, + MAX_INLINE_ATTACHMENT_BYTES, + MAX_INLINE_ATTACHMENT_COUNT, } from '../api/attachmentLimits.js'; import { removeBrowserSessionArtifacts } from '../browser/BrowserArtifactStore.js'; import type { - CommunicationStyleSelection, - ReasoningEffortSelection, - ResponseVerbositySelection, - ServiceTierSelection, + CommunicationStyleSelection, + ReasoningEffortSelection, + ResponseVerbositySelection, + ServiceTierSelection, } from '../config/types.js'; import { parseCompactionReplacementMessages } from '../context/compactionCheckpoint.js'; import { - findPendingSessionInteraction, - toPendingInteraction, + findPendingSessionInteraction, + toPendingInteraction, } from '../context/interactions.js'; import { - codeReviewMessageMetadata, - projectSessionReviews, - renderCodeReview, - renderReviewStatus, + codeReviewMessageMetadata, + projectSessionReviews, + renderCodeReview, + renderReviewStatus, } from '../context/reviews.js'; import { JSONLStore, parseSessionJSONL } from '../context/storage/JSONLStore.js'; import { - assertValidSessionId, - detectGitBranch, - getAcpRemoteSessionFilePath, - getBladeStorageRoot, - getProjectStoragePath, - getSessionFilePath, - getSessionGoalFilePath, - isValidSessionId, - normalizeLocalWorkspacePath, - unescapeProjectPath, + assertValidSessionId, + detectGitBranch, + getAcpRemoteSessionFilePath, + getBladeStorageRoot, + getProjectStoragePath, + getSessionFilePath, + getSessionGoalFilePath, + isValidSessionId, + normalizeLocalWorkspacePath, + unescapeProjectPath, } from '../context/storage/pathUtils.js'; import { - getProjectionDb, - type MetadataDeriver, - projectSessionSurfaceSummaryFields, - removeSessionFromProjection, - syncAcpRemoteScope, - syncAll, - syncAllAcpRemoteScopes, + getProjectionDb, + type MetadataDeriver, + projectSessionSurfaceSummaryFields, + removeSessionFromProjection, + syncAcpRemoteScope, + syncAll, + syncAllAcpRemoteScopes, } from '../context/storage/sqlite/projection.js'; -import { isSessionTaskFailure, toTaskFailure } from '../context/taskFailure.js'; import { - findCurrentTokenBudgetHandoff, - projectTokenBudgetHandoffEvent, + findCurrentTokenBudgetHandoff, + projectTokenBudgetHandoffEvent, } from '../context/TokenBudgetHandoff.js'; +import { isSessionTaskFailure, toTaskFailure } from '../context/taskFailure.js'; import type { - AcpRemoteWorkspaceDescriptorV1, - SessionEvent, - SessionPendingInteraction, - SessionPermissionMode, - SessionReviewTargetInfo, - SessionRewindMode, - SessionTaskDelivery, - SessionTaskDiffStat, - SessionTaskDispatch, - SessionTaskFailure, - SessionTaskIsolation, - SessionTaskKind, - SessionTaskPriority, - SessionTaskRetryRef, - SessionTaskStatus, - SessionTaskWorktree, + AcpRemoteWorkspaceDescriptorV1, + SessionEvent, + SessionPendingInteraction, + SessionPermissionMode, + SessionReviewTargetInfo, + SessionRewindMode, + SessionTaskDelivery, + SessionTaskDiffStat, + SessionTaskDispatch, + SessionTaskFailure, + SessionTaskIsolation, + SessionTaskKind, + SessionTaskPriority, + SessionTaskRetryRef, + SessionTaskStatus, + SessionTaskWorktree, } from '../context/types.js'; import { createLogger, LogCategory } from '../logging/Logger.js'; import type { JsonObject, JsonValue, SessionMessage } from '../store/types.js'; @@ -104,40 +104,40 @@ import { isReasoningEffortSelection } from './pi/reasoningEffort.js'; import { isResponseVerbositySelection } from './pi/responseVerbosity.js'; import { isServiceTierSelection } from './pi/serviceTier.js'; import { - compareRemoteSessionCatalogItems, - compareSessionCatalogItems, - type NormalizedRemoteSessionListOptions, - type NormalizedSessionListOptions, - type NormalizedSessionTaskFilters, - normalizeRemoteSessionListOptions, - normalizeSessionListOptions, - normalizeSessionTaskFilters, - paginateRemoteSessionCatalog, - paginateSessionCatalog, - type RemoteSessionCatalogItem, - type RemoteSessionListOptions, - type RemoteSessionScanOptions, - resolveRemoteSessionCursorBoundary, - resolveSessionCursorBoundary, - sessionCatalogSortKey, - type SessionListOptions, - type SessionScanOptions, -} from './sessionCatalog.js'; -import { - renderSessionMarkdown, - type SessionMarkdownExport, - type SessionMarkdownExportOptions, + renderSessionMarkdown, + type SessionMarkdownExport, + type SessionMarkdownExportOptions, } from './SessionMarkdownExporter.js'; +import { createStructuredOutputContract } from './StructuredOutputService.js'; +import { + compareRemoteSessionCatalogItems, + compareSessionCatalogItems, + type NormalizedRemoteSessionListOptions, + type NormalizedSessionListOptions, + type NormalizedSessionTaskFilters, + normalizeRemoteSessionListOptions, + normalizeSessionListOptions, + normalizeSessionTaskFilters, + paginateRemoteSessionCatalog, + paginateSessionCatalog, + type RemoteSessionCatalogItem, + type RemoteSessionListOptions, + type RemoteSessionScanOptions, + resolveRemoteSessionCursorBoundary, + resolveSessionCursorBoundary, + type SessionListOptions, + type SessionScanOptions, + sessionCatalogSortKey, +} from './sessionCatalog.js'; import { - listSessionRewindCheckpoints as listProjectedRewindCheckpoints, - materializeSessionEvents, - planSessionRewind, - type SessionRewindCheckpoint as ProjectedRewindCheckpoint, + listSessionRewindCheckpoints as listProjectedRewindCheckpoints, + materializeSessionEvents, + type SessionRewindCheckpoint as ProjectedRewindCheckpoint, + planSessionRewind, } from './sessionRewind.js'; -import { createStructuredOutputContract } from './StructuredOutputService.js'; import { - renderUserShellCommandForDisplay, - userShellCommandRecordFromMetadata, + renderUserShellCommandForDisplay, + userShellCommandRecordFromMetadata, } from './UserShellCommandService.js'; const logger = createLogger(LogCategory.SERVICE); @@ -1089,10 +1089,7 @@ export class SessionService { const projected = await this.listRemoteSessionPageFromProjection(normalized); if (projected) return projected; - const stored = await this.scanRemoteStoredSessions( - normalized, - normalized.cursor ? 5_000 : 0 - ); + const stored = await this.scanRemoteStoredSessions(normalized); const filtered = this.toRemoteCatalogEntries(stored).sort( compareRemoteSessionCatalogItems ); @@ -1227,7 +1224,7 @@ export class SessionService { ): Promise { resolveRemoteSessionCursorBoundary(options); try { - const sessions = await this.scanRemoteStoredSessionsFromProjection(options, 0); + const sessions = await this.scanRemoteStoredSessionsFromProjection(options); if (!sessions) return null; const page = paginateRemoteSessionCatalog( this.toRemoteCatalogEntries(sessions).sort(compareRemoteSessionCatalogItems), @@ -1278,7 +1275,7 @@ export class SessionService { } : options ); - const stored = await this.scanRemoteStoredSessions(normalized, 0); + const stored = await this.scanRemoteStoredSessions(normalized); const seenSessions = new Set(); return this.toRemoteCatalogEntries(stored) .sort(compareRemoteSessionCatalogItems) @@ -1912,155 +1909,17 @@ export class SessionService { const rootId = sourceCreated.data.rootId || sourceSessionId; const gitBranch = detectGitBranch(targetProjectPath); const version = getVersion(); - const { - status: _sourceStatus, - taskStatus: _sourceTaskStatus, - taskStatusReason: _sourceTaskStatusReason, - taskFailure: _sourceTaskFailure, - taskStartedAt: _sourceTaskStartedAt, - taskCompletedAt: _sourceTaskCompletedAt, - taskOwnerPid: _sourceTaskOwnerPid, - taskPromptSummary: _sourceTaskPromptSummary, - taskPriority: _sourceTaskPriority, - taskKind: _sourceTaskKind, - taskDueAt: _sourceTaskDueAt, - taskDispatch: _sourceTaskDispatch, - taskModelId: _sourceTaskModelId, - taskRetriedFrom: _sourceTaskRetriedFrom, - taskDelivery: _sourceTaskDelivery, - taskIsolation: _sourceTaskIsolation, - taskSourceProjectPath: _sourceTaskSourceProjectPath, - taskWorktree: _sourceTaskWorktree, - taskDiffStat: _sourceTaskDiffStat, - taskQueuePosition: _sourceTaskQueuePosition, - taskQueueDepth: _sourceTaskQueueDepth, - taskConcurrencyLimit: _sourceTaskConcurrencyLimit, - pendingInteraction: _sourcePendingInteraction, - ...sourceCreatedData - } = sourceCreated.data; - const childCreated: Extract = { - id: nanoid(), - sessionId: targetSessionId, - timestamp: now, - type: 'session_created', - cwd: targetProjectPath, - gitBranch, - version, - data: { - ...sourceCreatedData, - sessionId: targetSessionId, - rootId, - parentId: sourceSessionId, - relationType: 'fork', - taskStatus: 'completed', - taskCompletedAt: now, - taskIsolation: 'local', - taskSourceProjectPath: targetProjectPath, - createdAt: now, - updatedAt: now, - }, - }; - const copiedEntries = sourceEntries - .filter( - (entry) => - entry.type !== 'session_created' && - entry.type !== 'token_budget_handoff_recorded' && - entry.type !== 'inbox_acknowledged' && - entry.type !== 'interaction_requested' && - entry.type !== 'interaction_responded' && - entry.type !== 'interaction_recovered' && - entry.type !== 'review_started' && - entry.type !== 'review_completed' - ) - .map((entry): SessionEvent => { - const base = { - ...entry, - id: nanoid(), - sessionId: targetSessionId, - cwd: targetProjectPath, - gitBranch, - version, - }; - if (entry.type === 'session_updated') { - const { - status: _status, - taskStatus: _taskStatus, - taskStatusReason: _taskStatusReason, - taskFailure: _taskFailure, - taskStartedAt: _taskStartedAt, - taskCompletedAt: _taskCompletedAt, - taskOwnerPid: _taskOwnerPid, - taskPromptSummary: _taskPromptSummary, - taskPriority: _taskPriority, - taskKind: _taskKind, - taskDueAt: _taskDueAt, - taskDispatch: _taskDispatch, - taskModelId: _taskModelId, - taskRetriedFrom: _taskRetriedFrom, - taskDelivery: _taskDelivery, - taskIsolation: _taskIsolation, - taskSourceProjectPath: _taskSourceProjectPath, - taskWorktree: _taskWorktree, - taskDiffStat: _taskDiffStat, - taskQueuePosition: _taskQueuePosition, - taskQueueDepth: _taskQueueDepth, - taskConcurrencyLimit: _taskConcurrencyLimit, - pendingInteraction: _pendingInteraction, - ...updatedData - } = entry.data; - return { - ...base, - type: 'session_updated', - data: { - ...updatedData, - sessionId: targetSessionId, - rootId, - parentId: sourceSessionId, - relationType: 'fork', - }, - }; - } - if (entry.type === 'message_created') { - const { inboxMessageId: _inboxMessageId, ...data } = entry.data; - return { - ...base, - type: 'message_created', - data, - }; - } - return base as SessionEvent; - }); - const forkBoundary: Extract = { - id: nanoid(), - sessionId: targetSessionId, - timestamp: now, - type: 'session_updated', - cwd: targetProjectPath, - gitBranch, + const childEntries = this.buildForkChildEntries({ + sourceEntries, + sourceCreated, + sourceSessionId, + targetSessionId, + targetProjectPath, + rootId, version, - data: { - sessionId: targetSessionId, - rootId, - parentId: sourceSessionId, - relationType: 'fork', - taskStatus: 'completed', - taskStatusReason: null, - taskFailure: null, - taskStartedAt: null, - taskCompletedAt: now, - taskOwnerPid: null, - taskIsolation: 'local', - taskSourceProjectPath: targetProjectPath, - taskWorktree: null, - taskDiffStat: null, - taskDelivery: null, - taskQueuePosition: null, - taskQueueDepth: null, - taskConcurrencyLimit: null, - updatedAt: now, - }, - }; - const childEntries: SessionEvent[] = [childCreated, ...copiedEntries, forkBoundary]; + now, + target: { kind: 'local', gitBranch }, + }); const targetFilePath = getSessionFilePath(targetProjectPath, targetSessionId); let targetCreated = false; @@ -2195,7 +2054,10 @@ export class SessionService { rootId, version, now, - remoteDescriptor: sourceMetadata.remoteWorkspace, + target: { + kind: 'remote', + descriptor: sourceMetadata.remoteWorkspace, + }, }); const targetFilePath = getAcpRemoteSessionFilePath(scope, targetSessionId); let targetCreated = false; @@ -2326,7 +2188,9 @@ export class SessionService { rootId: string; version: string; now: string; - remoteDescriptor: AcpRemoteWorkspaceDescriptorV1; + target: + | { kind: 'local'; gitBranch?: string } + | { kind: 'remote'; descriptor: AcpRemoteWorkspaceDescriptorV1 }; }): SessionEvent[] { const { sourceEntries, @@ -2337,8 +2201,28 @@ export class SessionService { rootId, version, now, - remoteDescriptor, + target, } = options; + const eventTarget = + target.kind === 'remote' + ? { projectPath: targetProjectPath } + : { gitBranch: target.gitBranch }; + const createdTarget = + target.kind === 'remote' + ? { remoteWorkspace: target.descriptor } + : { + taskIsolation: 'local' as const, + taskSourceProjectPath: targetProjectPath, + }; + const boundaryTarget = + target.kind === 'remote' + ? {} + : { + taskIsolation: 'local' as const, + taskSourceProjectPath: targetProjectPath, + taskWorktree: null, + taskDiffStat: null, + }; const { status: _sourceStatus, taskStatus: _sourceTaskStatus, @@ -2369,7 +2253,7 @@ export class SessionService { const childCreated: Extract = { id: nanoid(), sessionId: targetSessionId, - projectPath: targetProjectPath, + ...eventTarget, timestamp: now, type: 'session_created', cwd: targetProjectPath, @@ -2382,7 +2266,7 @@ export class SessionService { relationType: 'fork', taskStatus: 'completed', taskCompletedAt: now, - remoteWorkspace: remoteDescriptor, + ...createdTarget, createdAt: now, updatedAt: now, }, @@ -2402,10 +2286,10 @@ export class SessionService { .map((entry): SessionEvent => { const { gitBranch: _sourceGitBranch, ...entryWithoutGitBranch } = entry; const base = { - ...entryWithoutGitBranch, + ...(target.kind === 'remote' ? entryWithoutGitBranch : entry), id: nanoid(), sessionId: targetSessionId, - projectPath: targetProjectPath, + ...eventTarget, cwd: targetProjectPath, version, }; @@ -2458,7 +2342,7 @@ export class SessionService { const forkBoundary: Extract = { id: nanoid(), sessionId: targetSessionId, - projectPath: targetProjectPath, + ...eventTarget, timestamp: now, type: 'session_updated', cwd: targetProjectPath, @@ -2474,6 +2358,7 @@ export class SessionService { taskStartedAt: null, taskCompletedAt: now, taskOwnerPid: null, + ...boundaryTarget, taskDelivery: null, taskQueuePosition: null, taskQueueDepth: null, @@ -3982,7 +3867,7 @@ export class SessionService { const projected = await this.scanStoredSessionsFromProjection( scopedProjectPath, includeSubagents, - 0, + projectionSyncMaxAgeMs, archived, taskFilters ); @@ -4100,13 +3985,9 @@ export class SessionService { } private static async scanRemoteStoredSessions( - options: NormalizedRemoteSessionListOptions, - projectionSyncMaxAgeMs = 0 + options: NormalizedRemoteSessionListOptions ): Promise { - const projected = await this.scanRemoteStoredSessionsFromProjection( - options, - projectionSyncMaxAgeMs - ); + const projected = await this.scanRemoteStoredSessionsFromProjection(options); if (projected) return projected; const scopes = await this.listRemoteSessionScopes(options); @@ -4160,8 +4041,7 @@ export class SessionService { } private static async scanRemoteStoredSessionsFromProjection( - options: NormalizedRemoteSessionListOptions, - projectionSyncMaxAgeMs = 0 + options: NormalizedRemoteSessionListOptions ): Promise { try { const db = await getProjectionDb(); @@ -4565,7 +4445,6 @@ export class SessionService { : path.resolve(committedProjectPath); const remoteWorkspace = this.parseRemoteWorkspaceFromCreated( created, - sessionId, resolvedProjectPath ); const parsedTaskWorktree = parseTaskWorktree(durable.taskWorktree); @@ -4747,7 +4626,6 @@ export class SessionService { private static parseRemoteWorkspaceFromCreated( created: Extract, - sessionId: string, resolvedProjectPath: string ): AcpRemoteWorkspaceDescriptorV1 | undefined { if (!Object.hasOwn(created.data, 'remoteWorkspace')) { From 0efea96c69602676b141f97306880190b1c58e2a Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:04:31 +0800 Subject: [PATCH 07/59] refactor(ui): deduplicate shared theme tokens --- packages/cli/src/ui/themes/presets.ts | 562 +++++--------------------- 1 file changed, 94 insertions(+), 468 deletions(-) diff --git a/packages/cli/src/ui/themes/presets.ts b/packages/cli/src/ui/themes/presets.ts index c79cd5f4a..215ac4eb0 100644 --- a/packages/cli/src/ui/themes/presets.ts +++ b/packages/cli/src/ui/themes/presets.ts @@ -3,6 +3,48 @@ */ import type { Theme } from './types.js'; +const spacing: Theme['spacing'] = { + xs: 0.25, + sm: 0.5, + md: 1, + lg: 1.5, + xl: 2, +}; + +const typography: Theme['typography'] = { + fontSize: { + xs: 0.75, + sm: 0.875, + base: 1, + lg: 1.125, + xl: 1.25, + '2xl': 1.5, + '3xl': 1.875, + }, + fontWeight: { + light: 300, + normal: 400, + medium: 500, + semibold: 600, + bold: 700, + }, +}; + +const borderRadius: Theme['borderRadius'] = { + sm: 0.125, + base: 0.25, + lg: 0.5, + xl: 0.75, +}; + +function shadows(rgb: string): Theme['boxShadow'] { + return { + sm: `0 1px 2px 0 rgba(${rgb}, 0.2)`, + base: `0 1px 3px 0 rgba(${rgb}, 0.3), 0 1px 2px 0 rgba(${rgb}, 0.2)`, + lg: `0 10px 15px -3px rgba(${rgb}, 0.3), 0 4px 6px -2px rgba(${rgb}, 0.2)`, + }; +} + // Ayu Dark 主题 const ayuDark: Theme = { name: 'ayu-dark', @@ -47,42 +89,10 @@ const ayuDark: Theme = { default: '#e6e1cf', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(0, 0, 0, 0.2)', - base: '0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2)', - lg: '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('0, 0, 0'), }; // Dracula 主题 @@ -129,42 +139,10 @@ const dracula: Theme = { default: '#f8f8f2', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(40, 42, 54, 0.2)', - base: '0 1px 3px 0 rgba(40, 42, 54, 0.3), 0 1px 2px 0 rgba(40, 42, 54, 0.2)', - lg: '0 10px 15px -3px rgba(40, 42, 54, 0.3), 0 4px 6px -2px rgba(40, 42, 54, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('40, 42, 54'), }; // Monokai 主题 @@ -211,42 +189,10 @@ const monokai: Theme = { default: '#f8f8f2', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(39, 40, 34, 0.2)', - base: '0 1px 3px 0 rgba(39, 40, 34, 0.3), 0 1px 2px 0 rgba(39, 40, 34, 0.2)', - lg: '0 10px 15px -3px rgba(39, 40, 34, 0.3), 0 4px 6px -2px rgba(39, 40, 34, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('39, 40, 34'), }; // Nord 主题 @@ -293,42 +239,10 @@ const nord: Theme = { default: '#eceff4', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(46, 52, 64, 0.2)', - base: '0 1px 3px 0 rgba(46, 52, 64, 0.3), 0 1px 2px 0 rgba(46, 52, 64, 0.2)', - lg: '0 10px 15px -3px rgba(46, 52, 64, 0.3), 0 4px 6px -2px rgba(46, 52, 64, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('46, 52, 64'), }; const solarizedLight: Theme = { @@ -374,42 +288,10 @@ const solarizedLight: Theme = { default: '#657b83', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(253, 246, 227, 0.2)', - base: '0 1px 3px 0 rgba(253, 246, 227, 0.3), 0 1px 2px 0 rgba(253, 246, 227, 0.2)', - lg: '0 10px 15px -3px rgba(253, 246, 227, 0.3), 0 4px 6px -2px rgba(253, 246, 227, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('253, 246, 227'), }; const solarizedDark: Theme = { @@ -455,42 +337,10 @@ const solarizedDark: Theme = { default: '#839496', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(0, 43, 54, 0.2)', - base: '0 1px 3px 0 rgba(0, 43, 54, 0.3), 0 1px 2px 0 rgba(0, 43, 54, 0.2)', - lg: '0 10px 15px -3px rgba(0, 43, 54, 0.3), 0 4px 6px -2px rgba(0, 43, 54, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('0, 43, 54'), }; // Tokyo Night 主题 @@ -537,42 +387,10 @@ const tokyoNight: Theme = { default: '#a9b1d6', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(26, 27, 38, 0.2)', - base: '0 1px 3px 0 rgba(26, 27, 38, 0.3), 0 1px 2px 0 rgba(26, 27, 38, 0.2)', - lg: '0 10px 15px -3px rgba(26, 27, 38, 0.3), 0 4px 6px -2px rgba(26, 27, 38, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('26, 27, 38'), }; // GitHub 主题 @@ -619,42 +437,10 @@ const github: Theme = { default: '#24292f', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(255, 255, 255, 0.2)', - base: '0 1px 3px 0 rgba(255, 255, 255, 0.3), 0 1px 2px 0 rgba(255, 255, 255, 0.2)', - lg: '0 10px 15px -3px rgba(255, 255, 255, 0.3), 0 4px 6px -2px rgba(255, 255, 255, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('255, 255, 255'), }; // Gruvbox 主题 @@ -701,42 +487,10 @@ const gruvbox: Theme = { default: '#ebdbb2', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(40, 40, 40, 0.2)', - base: '0 1px 3px 0 rgba(40, 40, 40, 0.3), 0 1px 2px 0 rgba(40, 40, 40, 0.2)', - lg: '0 10px 15px -3px rgba(40, 40, 40, 0.3), 0 4px 6px -2px rgba(40, 40, 40, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('40, 40, 40'), }; const oneDark: Theme = { @@ -782,42 +536,10 @@ const oneDark: Theme = { default: '#abb2bf', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(40, 44, 52, 0.2)', - base: '0 1px 3px 0 rgba(40, 44, 52, 0.3), 0 1px 2px 0 rgba(40, 44, 52, 0.2)', - lg: '0 10px 15px -3px rgba(40, 44, 52, 0.3), 0 4px 6px -2px rgba(40, 44, 52, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('40, 44, 52'), }; const catppuccin: Theme = { @@ -863,42 +585,10 @@ const catppuccin: Theme = { default: '#cdd6f4', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(30, 30, 46, 0.2)', - base: '0 1px 3px 0 rgba(30, 30, 46, 0.3), 0 1px 2px 0 rgba(30, 30, 46, 0.2)', - lg: '0 10px 15px -3px rgba(30, 30, 46, 0.3), 0 4px 6px -2px rgba(30, 30, 46, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('30, 30, 46'), }; // Rose Pine 主题 @@ -945,42 +635,10 @@ const rosePine: Theme = { default: '#e0def4', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(25, 23, 36, 0.2)', - base: '0 1px 3px 0 rgba(25, 23, 36, 0.3), 0 1px 2px 0 rgba(25, 23, 36, 0.2)', - lg: '0 10px 15px -3px rgba(25, 23, 36, 0.3), 0 4px 6px -2px rgba(25, 23, 36, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('25, 23, 36'), }; const kanagawa: Theme = { @@ -1026,42 +684,10 @@ const kanagawa: Theme = { default: '#dcd7ba', }, }, - spacing: { - xs: 0.25, - sm: 0.5, - md: 1, - lg: 1.5, - xl: 2, - }, - typography: { - fontSize: { - xs: 0.75, - sm: 0.875, - base: 1, - lg: 1.125, - xl: 1.25, - '2xl': 1.5, - '3xl': 1.875, - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - }, - borderRadius: { - sm: 0.125, - base: 0.25, - lg: 0.5, - xl: 0.75, - }, - boxShadow: { - sm: '0 1px 2px 0 rgba(31, 31, 40, 0.2)', - base: '0 1px 3px 0 rgba(31, 31, 40, 0.3), 0 1px 2px 0 rgba(31, 31, 40, 0.2)', - lg: '0 10px 15px -3px rgba(31, 31, 40, 0.3), 0 4px 6px -2px rgba(31, 31, 40, 0.2)', - }, + spacing, + typography, + borderRadius, + boxShadow: shadows('31, 31, 40'), }; interface ThemeItem { From 99d2dee4b88fdb0915b7e31bb4d301b97c274059 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:20:23 +0800 Subject: [PATCH 08/59] refactor(shell): encode read-only flags declaratively --- .../cli/src/utils/shell/readOnlyValidation.ts | 1571 ++++++----------- 1 file changed, 507 insertions(+), 1064 deletions(-) diff --git a/packages/cli/src/utils/shell/readOnlyValidation.ts b/packages/cli/src/utils/shell/readOnlyValidation.ts index 5dbeb4b6c..5d2602d30 100644 --- a/packages/cli/src/utils/shell/readOnlyValidation.ts +++ b/packages/cli/src/utils/shell/readOnlyValidation.ts @@ -32,6 +32,21 @@ export interface CommandConfig { respectsDoubleDash?: boolean; } +type FlagSpec = Partial>; + +function flags( + spec: FlagSpec, + ...sharedGroups: readonly Readonly>[] +): Record { + const result: Record = Object.assign({}, ...sharedGroups); + for (const argType of ['none', 'number', 'string'] as const) { + const names = spec[argType]?.trim(); + if (!names) continue; + for (const name of names.split(/\s+/)) result[name] = argType; + } + return result; +} + // ============================================================ // Shared flag groups (DRY helpers for git commands) // ============================================================ @@ -102,125 +117,93 @@ const GIT_FORMAT_FLAGS: Record = { export const GIT_READ_ONLY_COMMANDS: Record = { 'git diff': { - safeFlags: { - ...GIT_STAT_FLAGS, - ...GIT_COLOR_FLAGS, - ...GIT_PATCH_FLAGS, - '--cached': 'none', - '--staged': 'none', - '--no-index': 'none', - '--diff-filter': 'string', - '--word-diff': 'none', - '--word-diff-regex': 'string', - '-U': 'number', - '--unified': 'number', - '--compact-summary': 'none', - '--ignore-space-change': 'none', - '-b': 'none', - '--ignore-all-space': 'none', - '-w': 'none', - '--ignore-blank-lines': 'none', - '--src-prefix': 'string', - '--dst-prefix': 'string', - '--no-prefix': 'none', - '-R': 'none', - '--relative': 'none', - '--histogram': 'none', - '--patience': 'none', - '--minimal': 'none', - '--check': 'none', - '--ext-diff': 'none', - '--binary': 'none', - '--abbrev': 'none', - '--full-index': 'none', - '--break-rewrites': 'none', - '-B': 'none', - '--find-renames': 'none', - '-M': 'none', - '--find-copies': 'none', - '-C': 'none', - '--diff-algorithm': 'string', - }, + safeFlags: flags( + { + none: ` + --cached --staged --no-index --word-diff --compact-summary + --ignore-space-change -b --ignore-all-space -w + --ignore-blank-lines --no-prefix -R --relative --histogram + --patience --minimal --check --ext-diff --binary --abbrev + --full-index --break-rewrites -B --find-renames -M + --find-copies -C + `, + number: '-U --unified', + string: ` + --diff-filter --word-diff-regex --src-prefix --dst-prefix + --diff-algorithm + `, + }, + GIT_STAT_FLAGS, + GIT_COLOR_FLAGS, + GIT_PATCH_FLAGS + ), }, 'git log': { - safeFlags: { - ...GIT_REF_SELECTION_FLAGS, - ...GIT_DATE_FILTER_FLAGS, - ...GIT_LOG_DISPLAY_FLAGS, - ...GIT_COUNT_FLAGS, - ...GIT_STAT_FLAGS, - ...GIT_COLOR_FLAGS, - ...GIT_PATCH_FLAGS, - ...GIT_AUTHOR_FILTER_FLAGS, - ...GIT_FORMAT_FLAGS, - '--follow': 'none', - '--first-parent': 'none', - '--merges': 'none', - '--no-merges': 'none', - '--reverse': 'none', - '--ancestry-path': 'none', - '--simplify-by-decoration': 'none', - '--abbrev-commit': 'none', - '--no-abbrev-commit': 'none', - '--abbrev': 'number', - '--topo-order': 'none', - '--diff-filter': 'string', - '--skip': 'number', - '--left-right': 'none', - '--cherry-pick': 'none', - '--cherry-mark': 'none', - '--cherry': 'none', - '--walk-reflogs': 'none', - '-g': 'none', - '--boundary': 'none', - '--source': 'none', - }, + safeFlags: flags( + { + none: ` + --follow --first-parent --merges --no-merges --reverse + --ancestry-path --simplify-by-decoration --abbrev-commit + --no-abbrev-commit --topo-order --left-right --cherry-pick + --cherry-mark --cherry --walk-reflogs -g --boundary --source + `, + number: '--abbrev --skip', + string: '--diff-filter', + }, + GIT_REF_SELECTION_FLAGS, + GIT_DATE_FILTER_FLAGS, + GIT_LOG_DISPLAY_FLAGS, + GIT_COUNT_FLAGS, + GIT_STAT_FLAGS, + GIT_COLOR_FLAGS, + GIT_PATCH_FLAGS, + GIT_AUTHOR_FILTER_FLAGS, + GIT_FORMAT_FLAGS + ), }, 'git show': { - safeFlags: { - ...GIT_STAT_FLAGS, - ...GIT_COLOR_FLAGS, - ...GIT_PATCH_FLAGS, - ...GIT_FORMAT_FLAGS, - '--abbrev-commit': 'none', - '--no-abbrev-commit': 'none', - '--abbrev': 'number', - '-U': 'number', - '--unified': 'number', - '--diff-filter': 'string', - '--word-diff': 'none', - '--word-diff-regex': 'string', - '--compact-summary': 'none', - }, + safeFlags: flags( + { + none: ` + --abbrev-commit --no-abbrev-commit --word-diff + --compact-summary + `, + number: '--abbrev -U --unified', + string: '--diff-filter --word-diff-regex', + }, + GIT_STAT_FLAGS, + GIT_COLOR_FLAGS, + GIT_PATCH_FLAGS, + GIT_FORMAT_FLAGS + ), }, 'git shortlog': { - safeFlags: { - ...GIT_REF_SELECTION_FLAGS, - ...GIT_DATE_FILTER_FLAGS, - ...GIT_COUNT_FLAGS, - ...GIT_AUTHOR_FILTER_FLAGS, - '-s': 'none', - '--summary': 'none', - '-n': 'number', - '--numbered': 'none', - '-e': 'none', - '--email': 'none', - '--group': 'string', - '--format': 'string', - }, + safeFlags: flags( + { + none: '-s --summary --numbered -e --email', + number: '-n', + string: '--group --format', + }, + GIT_REF_SELECTION_FLAGS, + GIT_DATE_FILTER_FLAGS, + GIT_COUNT_FLAGS, + GIT_AUTHOR_FILTER_FLAGS + ), }, 'git reflog': { - safeFlags: { - ...GIT_LOG_DISPLAY_FLAGS, - ...GIT_COUNT_FLAGS, - ...GIT_COLOR_FLAGS, - ...GIT_FORMAT_FLAGS, - '--date': 'string', - }, + safeFlags: flags( + { + string: '--date', + }, + GIT_LOG_DISPLAY_FLAGS, + GIT_COUNT_FLAGS, + GIT_COLOR_FLAGS, + GIT_FORMAT_FLAGS + ), isDangerousCallback: (_raw, args) => { // reflog expire, delete, exists are dangerous const dangerousSubs = new Set(['expire', 'delete', 'exists']); @@ -232,144 +215,77 @@ export const GIT_READ_ONLY_COMMANDS: Record = { }, 'git stash list': { - safeFlags: { - ...GIT_LOG_DISPLAY_FLAGS, - ...GIT_COLOR_FLAGS, - ...GIT_FORMAT_FLAGS, - }, + safeFlags: flags({}, GIT_LOG_DISPLAY_FLAGS, GIT_COLOR_FLAGS, GIT_FORMAT_FLAGS), }, 'git stash show': { - safeFlags: { - ...GIT_STAT_FLAGS, - ...GIT_COLOR_FLAGS, - ...GIT_PATCH_FLAGS, - '-U': 'number', - '--unified': 'number', - '--include-untracked': 'none', - '-u': 'none', - }, + safeFlags: flags( + { + none: '--include-untracked -u', + number: '-U --unified', + }, + GIT_STAT_FLAGS, + GIT_COLOR_FLAGS, + GIT_PATCH_FLAGS + ), }, 'git ls-remote': { - safeFlags: { - '--heads': 'none', - '--tags': 'none', - '--refs': 'none', - '--quiet': 'none', - '-q': 'none', - '--get-url': 'none', - '--sort': 'string', - '--symref': 'none', - }, + safeFlags: flags({ + none: '--heads --tags --refs --quiet -q --get-url --symref', + string: '--sort', + }), }, 'git status': { - safeFlags: { - '--short': 'none', - '-s': 'none', - '--branch': 'none', - '-b': 'none', - '--porcelain': 'none', - '--long': 'none', - '--verbose': 'none', - '-v': 'none', - '--untracked-files': 'none', - '-u': 'none', - '--ignored': 'none', - '--ignore-submodules': 'none', - '--column': 'none', - '--no-column': 'none', - '--ahead-behind': 'none', - '--no-ahead-behind': 'none', - '--renames': 'none', - '--no-renames': 'none', - '--show-stash': 'none', - }, + safeFlags: flags({ + none: ` + --short -s --branch -b --porcelain --long --verbose -v + --untracked-files -u --ignored --ignore-submodules --column + --no-column --ahead-behind --no-ahead-behind --renames + --no-renames --show-stash + `, + }), }, 'git blame': { - safeFlags: { - '-L': 'string', - '--line-porcelain': 'none', - '--porcelain': 'none', - '-p': 'none', - '--show-name': 'none', - '--show-number': 'none', - '-n': 'none', - '--show-email': 'none', - '-e': 'none', - '-w': 'none', - '-M': 'none', - '-C': 'none', - '--date': 'string', - '--color-lines': 'none', - '--color-by-age': 'none', - '--abbrev': 'number', - '-s': 'none', - '--score-debug': 'none', - '--first-parent': 'none', - '--root': 'none', - '--since': 'string', - }, + safeFlags: flags({ + none: ` + --line-porcelain --porcelain -p --show-name --show-number -n + --show-email -e -w -M -C --color-lines --color-by-age -s + --score-debug --first-parent --root + `, + number: '--abbrev', + string: '-L --date --since', + }), }, 'git ls-files': { - safeFlags: { - '--cached': 'none', - '-c': 'none', - '--deleted': 'none', - '-d': 'none', - '--modified': 'none', - '-m': 'none', - '--others': 'none', - '-o': 'none', - '--ignored': 'none', - '-i': 'none', - '--stage': 'none', - '-s': 'none', - '--unmerged': 'none', - '-u': 'none', - '--killed': 'none', - '-k': 'none', - '--exclude': 'string', - '-x': 'string', - '--exclude-from': 'string', - '-X': 'string', - '--exclude-per-directory': 'string', - '--exclude-standard': 'none', - '--error-unmatch': 'none', - '--full-name': 'none', - '--recurse-submodules': 'none', - '-z': 'none', - '--eol': 'none', - '--deduplicate': 'none', - }, + safeFlags: flags({ + none: ` + --cached -c --deleted -d --modified -m --others -o --ignored + -i --stage -s --unmerged -u --killed -k --exclude-standard + --error-unmatch --full-name --recurse-submodules -z --eol + --deduplicate + `, + string: '--exclude -x --exclude-from -X --exclude-per-directory', + }), }, 'git config --get': { - safeFlags: { - '--global': 'none', - '--system': 'none', - '--local': 'none', - '--worktree': 'none', - '--get-regexp': 'none', - '--list': 'none', - '-l': 'none', - '--show-origin': 'none', - '--show-scope': 'none', - '-z': 'none', - '--null': 'none', - '--name-only': 'none', - '--type': 'string', - '--default': 'string', - }, + safeFlags: flags({ + none: ` + --global --system --local --worktree --get-regexp --list -l + --show-origin --show-scope -z --null --name-only + `, + string: '--type --default', + }), }, 'git remote show': { - safeFlags: { - '-n': 'none', - }, + safeFlags: flags({ + none: '-n', + }), isDangerousCallback: (_raw, args) => { // Must have exactly one alphanumeric remote name const nonFlags = args.filter((a) => !a.startsWith('-')); @@ -379,10 +295,9 @@ export const GIT_READ_ONLY_COMMANDS: Record = { }, 'git remote': { - safeFlags: { - '-v': 'none', - '--verbose': 'none', - }, + safeFlags: flags({ + none: '-v --verbose', + }), isDangerousCallback: (_raw, args) => { // Only bare `git remote` or `git remote -v` is safe // Any positional arg (add/remove/rename/set-url) is dangerous @@ -392,211 +307,107 @@ export const GIT_READ_ONLY_COMMANDS: Record = { }, 'git merge-base': { - safeFlags: { - '--all': 'none', - '--octopus': 'none', - '--is-ancestor': 'none', - '--independent': 'none', - '--fork-point': 'none', - }, + safeFlags: flags({ + none: '--all --octopus --is-ancestor --independent --fork-point', + }), }, 'git rev-parse': { - safeFlags: { - '--verify': 'none', - '--quiet': 'none', - '-q': 'none', - '--short': 'none', - '--symbolic': 'none', - '--symbolic-full-name': 'none', - '--abbrev-ref': 'none', - '--show-toplevel': 'none', - '--show-cdup': 'none', - '--show-prefix': 'none', - '--show-superproject-working-tree': 'none', - '--git-dir': 'none', - '--git-common-dir': 'none', - '--git-path': 'string', - '--is-inside-git-dir': 'none', - '--is-inside-work-tree': 'none', - '--is-bare-repository': 'none', - '--is-shallow-repository': 'none', - '--absolute-git-dir': 'none', - '--resolve-git-dir': 'string', - '--all': 'none', - '--branches': 'none', - '--tags': 'none', - '--remotes': 'none', - '--glob': 'string', - '--exclude': 'string', - }, + safeFlags: flags({ + none: ` + --verify --quiet -q --short --symbolic --symbolic-full-name + --abbrev-ref --show-toplevel --show-cdup --show-prefix + --show-superproject-working-tree --git-dir --git-common-dir + --is-inside-git-dir --is-inside-work-tree + --is-bare-repository --is-shallow-repository + --absolute-git-dir --all --branches --tags --remotes + `, + string: '--git-path --resolve-git-dir --glob --exclude', + }), }, 'git rev-list': { - safeFlags: { - ...GIT_REF_SELECTION_FLAGS, - ...GIT_DATE_FILTER_FLAGS, - ...GIT_COUNT_FLAGS, - '--count': 'none', - '--objects': 'none', - '--no-walk': 'none', - '--first-parent': 'none', - '--merges': 'none', - '--no-merges': 'none', - '--reverse': 'none', - '--ancestry-path': 'none', - '--topo-order': 'none', - '--left-right': 'none', - '--cherry-pick': 'none', - '--cherry-mark': 'none', - '--cherry': 'none', - '--boundary': 'none', - '--abbrev-commit': 'none', - '--abbrev': 'number', - '--header': 'none', - '--skip': 'number', - }, + safeFlags: flags( + { + none: ` + --count --objects --no-walk --first-parent --merges + --no-merges --reverse --ancestry-path --topo-order + --left-right --cherry-pick --cherry-mark --cherry --boundary + --abbrev-commit --header + `, + number: '--abbrev --skip', + }, + GIT_REF_SELECTION_FLAGS, + GIT_DATE_FILTER_FLAGS, + GIT_COUNT_FLAGS + ), }, 'git describe': { - safeFlags: { - '--all': 'none', - '--tags': 'none', - '--contains': 'none', - '--abbrev': 'number', - '--long': 'none', - '--first-parent': 'none', - '--always': 'none', - '--match': 'string', - '--exclude': 'string', - '--exact-match': 'none', - '--dirty': 'none', - '--broken': 'none', - '--candidates': 'number', - '--debug': 'none', - }, + safeFlags: flags({ + none: ` + --all --tags --contains --long --first-parent --always + --exact-match --dirty --broken --debug + `, + number: '--abbrev --candidates', + string: '--match --exclude', + }), }, 'git cat-file': { - safeFlags: { - '-t': 'none', - '-s': 'none', - '-e': 'none', - '-p': 'none', - '--batch': 'none', - '--batch-check': 'none', - '--batch-all-objects': 'none', - '--textconv': 'none', - '--filters': 'none', - '--allow-unknown-type': 'none', - '--buffer': 'none', - '--unordered': 'none', - }, + safeFlags: flags({ + none: ` + -t -s -e -p --batch --batch-check --batch-all-objects + --textconv --filters --allow-unknown-type --buffer + --unordered + `, + }), }, 'git for-each-ref': { - safeFlags: { - '--format': 'string', - '--sort': 'string', - '--count': 'number', - '--shell': 'none', - '--perl': 'none', - '--python': 'none', - '--tcl': 'none', - '--points-at': 'string', - '--merged': 'string', - '--no-merged': 'string', - '--contains': 'string', - '--no-contains': 'string', - }, + safeFlags: flags({ + none: '--shell --perl --python --tcl', + number: '--count', + string: ` + --format --sort --points-at --merged --no-merged --contains + --no-contains + `, + }), }, 'git grep': { - safeFlags: { - '-i': 'none', - '--ignore-case': 'none', - '-w': 'none', - '--word-regexp': 'none', - '-v': 'none', - '--invert-match': 'none', - '-n': 'none', - '--line-number': 'none', - '-l': 'none', - '--files-with-matches': 'none', - '--name-only': 'none', - '-L': 'none', - '--files-without-match': 'none', - '-c': 'none', - '--count': 'none', - '--color': 'none', - '--no-color': 'none', - '-e': 'string', - '-f': 'string', - '--and': 'none', - '--or': 'none', - '--not': 'none', - '--all-match': 'none', - '-E': 'none', - '--extended-regexp': 'none', - '-G': 'none', - '--basic-regexp': 'none', - '-P': 'none', - '--perl-regexp': 'none', - '-F': 'none', - '--fixed-strings': 'none', - '--cached': 'none', - '--untracked': 'none', - '--no-index': 'none', - '--recurse-submodules': 'none', - '--max-depth': 'number', - '-h': 'none', - '--no-filename': 'none', - '-H': 'none', - '--full-name': 'none', - '-z': 'none', - '--break': 'none', - '--heading': 'none', - '-p': 'none', - '--show-function': 'none', - '-W': 'none', - '--function-context': 'none', - '--threads': 'number', - '-O': 'string', - '--open-files-in-pager': 'none', - '-A': 'number', - '-B': 'number', - '-C': 'number', - '--context': 'number', - }, + safeFlags: flags({ + none: ` + -i --ignore-case -w --word-regexp -v --invert-match -n + --line-number -l --files-with-matches --name-only -L + --files-without-match -c --count --color --no-color --and + --or --not --all-match -E --extended-regexp -G + --basic-regexp -P --perl-regexp -F --fixed-strings --cached + --untracked --no-index --recurse-submodules -h --no-filename + -H --full-name -z --break --heading -p --show-function -W + --function-context --open-files-in-pager + `, + number: '--max-depth --threads -A -B -C --context', + string: '-e -f -O', + }), }, 'git worktree list': { - safeFlags: { - '--porcelain': 'none', - '-z': 'none', - '-v': 'none', - '--verbose': 'none', - '--expire': 'string', - }, + safeFlags: flags({ + none: '--porcelain -z -v --verbose', + string: '--expire', + }), }, 'git tag': { - safeFlags: { - '-l': 'none', - '--list': 'none', - '-n': 'number', - '--sort': 'string', - '--column': 'none', - '--no-column': 'none', - '--contains': 'string', - '--no-contains': 'string', - '--merged': 'string', - '--no-merged': 'string', - '--points-at': 'string', - '--format': 'string', - '--color': 'none', - '--no-color': 'none', - }, + safeFlags: flags({ + none: '-l --list --column --no-column --color --no-color', + number: '-n', + string: ` + --sort --contains --no-contains --merged --no-merged + --points-at --format + `, + }), isDangerousCallback: (_raw, args) => { // Safe only with -l/--list flag or no positional args const hasListFlag = args.some((a) => a === '-l' || a === '--list'); @@ -608,31 +419,17 @@ export const GIT_READ_ONLY_COMMANDS: Record = { }, 'git branch': { - safeFlags: { - '-l': 'none', - '--list': 'none', - '-a': 'none', - '--all': 'none', - '-r': 'none', - '--remotes': 'none', - '-v': 'none', - '--verbose': 'none', - '-vv': 'none', - '--sort': 'string', - '--format': 'string', - '--color': 'none', - '--no-color': 'none', - '--column': 'none', - '--no-column': 'none', - '--contains': 'string', - '--no-contains': 'string', - '--merged': 'string', - '--no-merged': 'string', - '--points-at': 'string', - '--abbrev': 'number', - '--no-abbrev': 'none', - '--show-current': 'none', - }, + safeFlags: flags({ + none: ` + -l --list -a --all -r --remotes -v --verbose -vv --color + --no-color --column --no-column --no-abbrev --show-current + `, + number: '--abbrev', + string: ` + --sort --format --contains --no-contains --merged + --no-merged --points-at + `, + }), isDangerousCallback: (_raw, args) => { // Safe with --list, -a, -r, --show-current, or bare git branch const listFlags = new Set([ @@ -700,205 +497,188 @@ const GH_COMMON_FLAGS: Record = { export const GH_READ_ONLY_COMMANDS: Record = { 'gh pr view': { - safeFlags: { ...GH_COMMON_FLAGS }, + safeFlags: flags({}, GH_COMMON_FLAGS), isDangerousCallback: ghIsDangerousCallback, }, 'gh pr list': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--state': 'string', - '-s': 'string', - '--author': 'string', - '--label': 'string', - '--base': 'string', - '--head': 'string', - '--search': 'string', - '--assignee': 'string', - '--draft': 'none', - }, + safeFlags: flags( + { + none: '--draft', + string: ` + --state -s --author --label --base --head --search + --assignee + `, + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh pr diff': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--color': 'string', - '--patch': 'none', - '--name-only': 'none', - }, + safeFlags: flags( + { + none: '--patch --name-only', + string: '--color', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh pr checks': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--watch': 'none', - '--fail-fast': 'none', - '--required': 'none', - }, + safeFlags: flags( + { + none: '--watch --fail-fast --required', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh pr status': { - safeFlags: { ...GH_COMMON_FLAGS }, + safeFlags: flags({}, GH_COMMON_FLAGS), isDangerousCallback: ghIsDangerousCallback, }, 'gh issue view': { - safeFlags: { ...GH_COMMON_FLAGS }, + safeFlags: flags({}, GH_COMMON_FLAGS), isDangerousCallback: ghIsDangerousCallback, }, 'gh issue list': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--state': 'string', - '-s': 'string', - '--author': 'string', - '--label': 'string', - '--search': 'string', - '--assignee': 'string', - '--milestone': 'string', - }, + safeFlags: flags( + { + string: '--state -s --author --label --search --assignee --milestone', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh issue status': { - safeFlags: { ...GH_COMMON_FLAGS }, + safeFlags: flags({}, GH_COMMON_FLAGS), isDangerousCallback: ghIsDangerousCallback, }, 'gh repo view': { - safeFlags: { ...GH_COMMON_FLAGS }, + safeFlags: flags({}, GH_COMMON_FLAGS), isDangerousCallback: ghIsDangerousCallback, }, 'gh run list': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--workflow': 'string', - '-w': 'string', - '--branch': 'string', - '-b': 'string', - '--status': 'string', - '-s': 'string', - '--user': 'string', - '-u': 'string', - '--event': 'string', - '-e': 'string', - }, + safeFlags: flags( + { + string: '--workflow -w --branch -b --status -s --user -u --event -e', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh run view': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--log': 'none', - '--log-failed': 'none', - '--exit-status': 'none', - '--verbose': 'none', - '-v': 'none', - '--job': 'string', - '-j': 'string', - }, + safeFlags: flags( + { + none: '--log --log-failed --exit-status --verbose -v', + string: '--job -j', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh auth status': { - safeFlags: { '--hostname': 'string', '-h': 'string', '--active': 'none' }, + safeFlags: flags({ + none: '--active', + string: '--hostname -h', + }), isDangerousCallback: (_raw, args) => { // Block --show-token / -t (leaks credentials) return args.some((a) => a === '--show-token' || a === '-t'); }, }, 'gh release list': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--exclude-drafts': 'none', - '--exclude-pre-releases': 'none', - }, + safeFlags: flags( + { + none: '--exclude-drafts --exclude-pre-releases', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh release view': { - safeFlags: { ...GH_COMMON_FLAGS }, + safeFlags: flags({}, GH_COMMON_FLAGS), isDangerousCallback: ghIsDangerousCallback, }, 'gh workflow list': { - safeFlags: { ...GH_COMMON_FLAGS, '--all': 'none', '-a': 'none' }, + safeFlags: flags( + { + none: '--all -a', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh workflow view': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--yaml': 'none', - '-y': 'none', - '--ref': 'string', - '-r': 'string', - }, + safeFlags: flags( + { + none: '--yaml -y', + string: '--ref -r', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh label list': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--search': 'string', - '--sort': 'string', - '--order': 'string', - }, + safeFlags: flags( + { + string: '--search --sort --order', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh search repos': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--language': 'string', - '--topic': 'string', - '--sort': 'string', - '--order': 'string', - '--match': 'string', - '--owner': 'string', - '--visibility': 'string', - }, + safeFlags: flags( + { + string: ` + --language --topic --sort --order --match --owner + --visibility + `, + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh search issues': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--sort': 'string', - '--order': 'string', - '--match': 'string', - '--state': 'string', - '--label': 'string', - '--language': 'string', - '--author': 'string', - '--assignee': 'string', - '--repo': 'string', - }, + safeFlags: flags( + { + string: ` + --sort --order --match --state --label --language --author + --assignee --repo + `, + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh search prs': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--sort': 'string', - '--order': 'string', - '--match': 'string', - '--state': 'string', - '--label': 'string', - '--language': 'string', - '--author': 'string', - '--assignee': 'string', - '--repo': 'string', - }, + safeFlags: flags( + { + string: ` + --sort --order --match --state --label --language --author + --assignee --repo + `, + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh search commits': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--sort': 'string', - '--order': 'string', - '--author': 'string', - '--committer': 'string', - '--repo': 'string', - }, + safeFlags: flags( + { + string: '--sort --order --author --committer --repo', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, 'gh search code': { - safeFlags: { - ...GH_COMMON_FLAGS, - '--language': 'string', - '--filename': 'string', - '--extension': 'string', - '--repo': 'string', - '--match': 'string', - }, + safeFlags: flags( + { + string: '--language --filename --extension --repo --match', + }, + GH_COMMON_FLAGS + ), isDangerousCallback: ghIsDangerousCallback, }, }; @@ -909,159 +689,52 @@ export const GH_READ_ONLY_COMMANDS: Record = { export const DOCKER_READ_ONLY_COMMANDS: Record = { 'docker logs': { - safeFlags: { - '--follow': 'none', - '-f': 'none', - '--tail': 'number', - '-n': 'number', - '--timestamps': 'none', - '-t': 'none', - '--since': 'string', - '--until': 'string', - '--details': 'none', - }, + safeFlags: flags({ + none: '--follow -f --timestamps -t --details', + number: '--tail -n', + string: '--since --until', + }), }, 'docker inspect': { - safeFlags: { - '--format': 'string', - '-f': 'string', - '--type': 'string', - '--size': 'none', - '-s': 'none', - }, + safeFlags: flags({ + none: '--size -s', + string: '--format -f --type', + }), }, }; export const RIPGREP_READ_ONLY_COMMANDS: Record = { rg: { - safeFlags: { - // Pattern flags - '-e': 'string', - '--regexp': 'string', - '-F': 'none', - '--fixed-strings': 'none', - '-i': 'none', - '--ignore-case': 'none', - '-S': 'none', - '--smart-case': 'none', - '-s': 'none', - '--case-sensitive': 'none', - '-v': 'none', - '--invert-match': 'none', - '-w': 'none', - '--word-regexp': 'none', - '-x': 'none', - '--line-regexp': 'none', - '-P': 'none', - '--pcre2': 'none', - '--engine': 'string', - // Search options - '-m': 'number', - '--max-count': 'number', - '--max-depth': 'number', - '--maxdepth': 'number', - '-d': 'number', - '--max-filesize': 'string', - '--mmap': 'none', - '--no-mmap': 'none', - '-U': 'none', - '--multiline': 'none', - '--multiline-dotall': 'none', - '--crlf': 'none', - '--no-crlf': 'none', - // Output options - '-c': 'none', - '--count': 'none', - '--count-matches': 'none', - '-l': 'none', - '--files-with-matches': 'none', - '--files-without-match': 'none', - '-o': 'none', - '--only-matching': 'none', - '--vimgrep': 'none', - '-r': 'string', - '--replace': 'string', - // File filtering - '-t': 'string', - '--type': 'string', - '-T': 'string', - '--type-not': 'string', - '-g': 'string', - '--glob': 'string', - '--iglob': 'string', - '--type-add': 'string', - '--type-clear': 'string', - // Display - '-A': 'number', - '--after-context': 'number', - '-B': 'number', - '--before-context': 'number', - '-C': 'number', - '--context': 'number', - '--color': 'string', - '--colors': 'string', - '-n': 'none', - '--line-number': 'none', - '-N': 'none', - '--no-line-number': 'none', - '-H': 'none', - '--with-filename': 'none', - '--no-filename': 'none', - '-p': 'none', - '--pretty': 'none', - '--heading': 'none', - '--no-heading': 'none', - '--column': 'none', - '--no-column': 'none', - '--byte-offset': 'none', - '--trim': 'none', - // Misc - '-j': 'number', - '--threads': 'number', - '--sort': 'string', - '--sortr': 'string', - '--stats': 'none', - '--no-ignore': 'none', - '--no-ignore-vcs': 'none', - '--no-ignore-parent': 'none', - '--no-ignore-global': 'none', - '--hidden': 'none', - '--no-hidden': 'none', - '-L': 'none', - '--follow': 'none', - '--one-file-system': 'none', - '--null': 'none', - '-0': 'none', - '--path-separator': 'string', - '--no-config': 'none', - '--no-ignore-dot': 'none', - '--no-ignore-exclude': 'none', - '--no-unicode': 'none', - '--pcre2-version': 'none', - '-q': 'none', - '--quiet': 'none', - '--help': 'none', - '-h': 'none', - '--version': 'none', - '-V': 'none', - '--': 'none', - '--json': 'none', - '--auto-hybrid-regex': 'none', - '--binary': 'none', - '--block-buffered': 'none', - '--line-buffered': 'none', - '--debug': 'none', - '--dfa-size-limit': 'string', - '--encoding': 'string', - '-E': 'string', - '--no-messages': 'none', - '--regex-size-limit': 'string', - '--search-zip': 'none', - '-z': 'none', - '--type-list': 'none', - '--unrestricted': 'none', - '-u': 'none', - }, + safeFlags: flags({ + none: ` + -F --fixed-strings -i --ignore-case -S --smart-case -s + --case-sensitive -v --invert-match -w --word-regexp -x + --line-regexp -P --pcre2 --mmap --no-mmap -U --multiline + --multiline-dotall --crlf --no-crlf -c --count + --count-matches -l --files-with-matches + --files-without-match -o --only-matching --vimgrep -n + --line-number -N --no-line-number -H --with-filename + --no-filename -p --pretty --heading --no-heading --column + --no-column --byte-offset --trim --stats --no-ignore + --no-ignore-vcs --no-ignore-parent --no-ignore-global + --hidden --no-hidden -L --follow --one-file-system --null -0 + --no-config --no-ignore-dot --no-ignore-exclude --no-unicode + --pcre2-version -q --quiet --help -h --version -V -- --json + --auto-hybrid-regex --binary --block-buffered + --line-buffered --debug --no-messages --search-zip -z + --type-list --unrestricted -u + `, + number: ` + -m --max-count --max-depth --maxdepth -d -A --after-context + -B --before-context -C --context -j --threads + `, + string: ` + -e --regexp --engine --max-filesize -r --replace -t --type + -T --type-not -g --glob --iglob --type-add --type-clear + --color --colors --sort --sortr --path-separator + --dfa-size-limit --encoding -E --regex-size-limit + `, + }), }, }; @@ -1078,214 +751,88 @@ const COMMAND_ALLOWLIST: Record = { // Additional safe commands with flag validation file: { - safeFlags: { - '-b': 'none', - '--brief': 'none', - '-i': 'none', - '--mime': 'none', - '--mime-type': 'none', - '--mime-encoding': 'none', - '-L': 'none', - '-h': 'none', - '--no-dereference': 'none', - '-z': 'none', - }, + safeFlags: flags({ + none: ` + -b --brief -i --mime --mime-type --mime-encoding -L -h + --no-dereference -z + `, + }), }, sort: { - safeFlags: { - '-r': 'none', - '--reverse': 'none', - '-n': 'none', - '--numeric-sort': 'none', - '-u': 'none', - '--unique': 'none', - '-k': 'string', - '--key': 'string', - '-t': 'string', - '--field-separator': 'string', - '-f': 'none', - '--ignore-case': 'none', - '-s': 'none', - '--stable': 'none', - '-h': 'none', - '--human-numeric-sort': 'none', - '-V': 'none', - '--version-sort': 'none', - '-g': 'none', - '--general-numeric-sort': 'none', - '-M': 'none', - '--month-sort': 'none', - }, + safeFlags: flags({ + none: ` + -r --reverse -n --numeric-sort -u --unique -f --ignore-case + -s --stable -h --human-numeric-sort -V --version-sort -g + --general-numeric-sort -M --month-sort + `, + string: '-k --key -t --field-separator', + }), }, grep: { - safeFlags: { - '-i': 'none', - '--ignore-case': 'none', - '-v': 'none', - '--invert-match': 'none', - '-c': 'none', - '--count': 'none', - '-l': 'none', - '--files-with-matches': 'none', - '-L': 'none', - '--files-without-match': 'none', - '-n': 'none', - '--line-number': 'none', - '-H': 'none', - '--with-filename': 'none', - '-h': 'none', - '--no-filename': 'none', - '-r': 'none', - '-R': 'none', - '--recursive': 'none', - '-E': 'none', - '--extended-regexp': 'none', - '-F': 'none', - '--fixed-strings': 'none', - '-P': 'none', - '--perl-regexp': 'none', - '-w': 'none', - '--word-regexp': 'none', - '-x': 'none', - '--line-regexp': 'none', - '-A': 'number', - '--after-context': 'number', - '-B': 'number', - '--before-context': 'number', - '-C': 'number', - '--context': 'number', - '-m': 'number', - '--max-count': 'number', - '--color': 'string', - '--colour': 'string', - '-e': 'string', - '--regexp': 'string', - '--include': 'string', - '--exclude': 'string', - '--exclude-dir': 'string', - '-o': 'none', - '--only-matching': 'none', - '-q': 'none', - '--quiet': 'none', - '--silent': 'none', - '-s': 'none', - '--no-messages': 'none', - '-Z': 'none', - '--null': 'none', - }, + safeFlags: flags({ + none: ` + -i --ignore-case -v --invert-match -c --count -l + --files-with-matches -L --files-without-match -n + --line-number -H --with-filename -h --no-filename -r -R + --recursive -E --extended-regexp -F --fixed-strings -P + --perl-regexp -w --word-regexp -x --line-regexp -o + --only-matching -q --quiet --silent -s --no-messages -Z + --null + `, + number: ` + -A --after-context -B --before-context -C --context -m + --max-count + `, + string: ` + --color --colour -e --regexp --include --exclude + --exclude-dir + `, + }), }, tree: { - safeFlags: { - '-L': 'number', - '-d': 'none', - '-a': 'none', - '-f': 'none', - '-i': 'none', - '-l': 'none', - '-s': 'none', - '-h': 'none', - '-p': 'none', - '-u': 'none', - '-g': 'none', - '-D': 'none', - '-r': 'none', - '-t': 'none', - '--noreport': 'none', - '--dirsfirst': 'none', - '-C': 'none', - '--color': 'none', - '-n': 'none', - '-I': 'string', - '-P': 'string', - '--charset': 'string', - '-o': 'string', - '--prune': 'none', - '-J': 'none', - '-X': 'none', - '-H': 'string', - }, + safeFlags: flags({ + none: ` + -d -a -f -i -l -s -h -p -u -g -D -r -t --noreport + --dirsfirst -C --color -n --prune -J -X + `, + number: '-L', + string: '-I -P --charset -o -H', + }), }, date: { - safeFlags: { - '-u': 'none', - '--utc': 'none', - '-d': 'string', - '--date': 'string', - '-I': 'none', - '--iso-8601': 'none', - '-R': 'none', - '--rfc-2822': 'none', - '--rfc-3339': 'string', - }, + safeFlags: flags({ + none: '-u --utc -I --iso-8601 -R --rfc-2822', + string: '-d --date --rfc-3339', + }), }, ps: { - safeFlags: { - '-e': 'none', - '-f': 'none', - '-l': 'none', - '-a': 'none', - '-u': 'none', - '-x': 'none', - '-o': 'string', - '--sort': 'string', - '-p': 'string', - '--pid': 'string', - '-C': 'string', - '--forest': 'none', - '-H': 'none', - '--headers': 'none', - '--no-headers': 'none', - '-w': 'none', - '--width': 'number', - }, + safeFlags: flags({ + none: '-e -f -l -a -u -x --forest -H --headers --no-headers -w', + number: '--width', + string: '-o --sort -p --pid -C', + }), }, lsof: { - safeFlags: { - '-i': 'string', - '-p': 'string', - '-n': 'none', - '-P': 'none', - '-t': 'none', - '-c': 'string', - '-u': 'string', - '-d': 'string', - '-a': 'none', - }, + safeFlags: flags({ + none: '-n -P -t -a', + string: '-i -p -c -u -d', + }), }, netstat: { - safeFlags: { - '-t': 'none', - '-u': 'none', - '-l': 'none', - '-n': 'none', - '-p': 'none', - '-a': 'none', - '-r': 'none', - '-s': 'none', - '-e': 'none', - '-o': 'none', - '-i': 'none', - }, + safeFlags: flags({ + none: '-t -u -l -n -p -a -r -s -e -o -i', + }), }, man: { - safeFlags: { - '-k': 'string', - '--apropos': 'string', - '-f': 'string', - '--whatis': 'string', - }, + safeFlags: flags({ + string: '-k --apropos -f --whatis', + }), }, sed: { - safeFlags: { - '-n': 'none', - '--quiet': 'none', - '--silent': 'none', - '-E': 'none', - '-r': 'none', - '--regexp-extended': 'none', - '-e': 'string', - '--expression': 'string', - }, + safeFlags: flags({ + none: '-n --quiet --silent -E -r --regexp-extended', + string: '-e --expression', + }), isDangerousCallback: (_raw, args) => { // sed is read-only only with -n (suppress output) and p/d/s patterns // Reject -i (in-place edit) and w command @@ -1298,183 +845,79 @@ const COMMAND_ALLOWLIST: Record = { }, }, base64: { - safeFlags: { - '-d': 'none', - '--decode': 'none', - '-w': 'number', - '--wrap': 'number', - '-i': 'none', - '--ignore-garbage': 'none', - }, + safeFlags: flags({ + none: '-d --decode -i --ignore-garbage', + number: '-w --wrap', + }), }, sha256sum: { - safeFlags: { - '-c': 'none', - '--check': 'none', - '-b': 'none', - '--binary': 'none', - '-t': 'none', - '--text': 'none', - '--tag': 'none', - '--status': 'none', - '-w': 'none', - '--warn': 'none', - '--strict': 'none', - '--quiet': 'none', - }, + safeFlags: flags({ + none: ` + -c --check -b --binary -t --text --tag --status -w --warn + --strict --quiet + `, + }), }, sha1sum: { - safeFlags: { - '-c': 'none', - '--check': 'none', - '-b': 'none', - '--binary': 'none', - '--tag': 'none', - '--status': 'none', - }, + safeFlags: flags({ + none: '-c --check -b --binary --tag --status', + }), }, md5sum: { - safeFlags: { - '-c': 'none', - '--check': 'none', - '-b': 'none', - '--binary': 'none', - '--tag': 'none', - '--status': 'none', - }, + safeFlags: flags({ + none: '-c --check -b --binary --tag --status', + }), }, hostname: { - safeFlags: { - '-s': 'none', - '-f': 'none', - '--fqdn': 'none', - '-d': 'none', - '-i': 'none', - '-I': 'none', - '-a': 'none', - }, + safeFlags: flags({ + none: '-s -f --fqdn -d -i -I -a', + }), }, pgrep: { - safeFlags: { - '-l': 'none', - '-a': 'none', - '-f': 'none', - '-x': 'none', - '-n': 'none', - '-o': 'none', - '-c': 'none', - '-d': 'string', - '-u': 'string', - '-U': 'string', - '-P': 'string', - '-G': 'string', - '-t': 'string', - }, + safeFlags: flags({ + none: '-l -a -f -x -n -o -c', + string: '-d -u -U -P -G -t', + }), }, ss: { - safeFlags: { - '-t': 'none', - '-u': 'none', - '-l': 'none', - '-n': 'none', - '-p': 'none', - '-a': 'none', - '-r': 'none', - '-s': 'none', - '-e': 'none', - '-o': 'none', - '-i': 'none', - '-4': 'none', - '-6': 'none', - '-m': 'none', - '-Z': 'none', - '-K': 'none', - }, + safeFlags: flags({ + none: '-t -u -l -n -p -a -r -s -e -o -i -4 -6 -m -Z -K', + }), }, fd: { - safeFlags: { - '-t': 'string', - '--type': 'string', - '-e': 'string', - '--extension': 'string', - '-E': 'string', - '--exclude': 'string', - '-d': 'number', - '--max-depth': 'number', - '-H': 'none', - '--hidden': 'none', - '-I': 'none', - '--no-ignore': 'none', - '-s': 'none', - '--case-sensitive': 'none', - '-i': 'none', - '--ignore-case': 'none', - '-a': 'none', - '--absolute-path': 'none', - '-l': 'none', - '--list-details': 'none', - '-L': 'none', - '--follow': 'none', - '-p': 'none', - '--full-path': 'none', - '-0': 'none', - '--print0': 'none', - '-1': 'none', - '--color': 'string', - '--glob': 'none', - '-g': 'none', - '-F': 'none', - '--fixed-strings': 'none', - '--prune': 'none', - '-u': 'none', - '--unrestricted': 'none', - '-S': 'string', - '--size': 'string', - '--changed-within': 'string', - '--changed-before': 'string', - '-j': 'number', - '--threads': 'number', - }, + safeFlags: flags({ + none: ` + -H --hidden -I --no-ignore -s --case-sensitive -i + --ignore-case -a --absolute-path -l --list-details -L + --follow -p --full-path -0 --print0 -1 --glob -g -F + --fixed-strings --prune -u --unrestricted + `, + number: '-d --max-depth -j --threads', + string: ` + -t --type -e --extension -E --exclude --color -S --size + --changed-within --changed-before + `, + }), }, fdfind: { - safeFlags: { - '-t': 'string', - '--type': 'string', - '-e': 'string', - '--extension': 'string', - '-E': 'string', - '--exclude': 'string', - '-d': 'number', - '--max-depth': 'number', - '-H': 'none', - '--hidden': 'none', - '-I': 'none', - '--no-ignore': 'none', - '-s': 'none', - '--case-sensitive': 'none', - '-i': 'none', - '--ignore-case': 'none', - '-a': 'none', - '--absolute-path': 'none', - '-l': 'none', - '--list-details': 'none', - '-L': 'none', - '--follow': 'none', - '-p': 'none', - '--full-path': 'none', - '-0': 'none', - '--print0': 'none', - '-1': 'none', - '--color': 'string', - '--glob': 'none', - '-g': 'none', - '-F': 'none', - '--fixed-strings': 'none', - }, - }, - tput: { safeFlags: { '-S': 'none' } }, - help: { safeFlags: {} }, - info: { safeFlags: {} }, + safeFlags: flags({ + none: ` + -H --hidden -I --no-ignore -s --case-sensitive -i + --ignore-case -a --absolute-path -l --list-details -L + --follow -p --full-path -0 --print0 -1 --glob -g -F + --fixed-strings + `, + number: '-d --max-depth', + string: '-t --type -e --extension -E --exclude --color', + }), + }, + tput: { + safeFlags: flags({ + none: '-S', + }), + }, + help: { safeFlags: flags({}) }, + info: { safeFlags: flags({}) }, }; // ============================================================ From 32e7cb0a453d9820e62021cc6d9217e87f97384e Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:20:51 +0800 Subject: [PATCH 09/59] refactor(subagents): remove deprecated global tool registry --- .../src/agent/subagents/SubagentRegistry.ts | 9 --------- packages/cli/src/tools/builtin/task/index.ts | 2 +- packages/cli/src/tools/builtin/task/task.ts | 6 +----- .../acp-session-fork-trajectory.test.ts | 5 ++++- ...oordination-reclamation-trajectory.test.ts | 5 ++++- .../runtime-session-fork-trajectory.test.ts | 5 ++++- .../subagent-resume-trajectory.test.ts | 6 ++++-- .../subagent-worktree-trajectory.test.ts | 6 ++++-- .../real-api/subagentResultAdoptionFixture.ts | 7 +++++-- .../tui-session-fork-trajectory.test.tsx | 5 ++++- .../tui-subagent-resume-trajectory.test.tsx | 5 ++++- .../real-api/web-session-trajectory.test.ts | 5 ++++- .../web-task-dispatch-trajectory.test.ts | 5 ++++- .../unit/agent-runtime/acp/session.test.ts | 4 +--- .../agent/subagent-event-forwarding.test.ts | 20 +++++++++++++------ .../agent-runtime/agent/task-resume.test.ts | 11 +++++++--- .../tooling/tools/builtin/team-tools.test.ts | 5 ++++- 17 files changed, 70 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/agent/subagents/SubagentRegistry.ts b/packages/cli/src/agent/subagents/SubagentRegistry.ts index 6de090eac..9c7c3e9e8 100644 --- a/packages/cli/src/agent/subagents/SubagentRegistry.ts +++ b/packages/cli/src/agent/subagents/SubagentRegistry.ts @@ -412,12 +412,3 @@ export function getSubagentRegistry( ): SubagentRegistry { return SubagentRegistry.getInstance(workspaceRoot); } - -/** @deprecated Use getSubagentRegistry(workspaceRoot). */ -export const subagentRegistry = new Proxy({} as SubagentRegistry, { - get(_target, property) { - const registry = getSubagentRegistry(); - const value = Reflect.get(registry, property, registry) as unknown; - return typeof value === 'function' ? value.bind(registry) : value; - }, -}); diff --git a/packages/cli/src/tools/builtin/task/index.ts b/packages/cli/src/tools/builtin/task/index.ts index 378252833..d7a1dd02f 100644 --- a/packages/cli/src/tools/builtin/task/index.ts +++ b/packages/cli/src/tools/builtin/task/index.ts @@ -1,6 +1,6 @@ // Subagent Task 工具导出 -export { createTaskTool, taskTool } from './task.js'; +export { createTaskTool } from './task.js'; export { createTaskListTools } from './taskListTools.js'; export type { TaskListItem, TaskStatus } from './taskListTypes.js'; export { taskOutputTool } from './taskOutput.js'; diff --git a/packages/cli/src/tools/builtin/task/task.ts b/packages/cli/src/tools/builtin/task/task.ts index 7c5746d4e..da17ee207 100644 --- a/packages/cli/src/tools/builtin/task/task.ts +++ b/packages/cli/src/tools/builtin/task/task.ts @@ -9,8 +9,8 @@ * 6. 会话恢复 - 支持 resume 参数 */ -import path from 'node:path'; import { nanoid } from 'nanoid'; +import path from 'node:path'; import type { LoopEvent } from '../../../agent/loop/types.js'; import type { SessionAgentResources } from '../../../agent/resources/WorkspaceAgentResources.js'; import type { SessionModelResources } from '../../../agent/resources/WorkspaceModelResources.js'; @@ -25,7 +25,6 @@ import { SubagentExecutor } from '../../../agent/subagents/SubagentExecutor.js'; import { getSubagentRegistry, type SubagentRegistry, - subagentRegistry, } from '../../../agent/subagents/SubagentRegistry.js'; import { buildCompletedSubagentTaskResult } from '../../../agent/subagents/SubagentResultAdoption.js'; import { @@ -452,9 +451,6 @@ export function createTaskTool(deps: SubagentDelegationDeps = {}) { }); } -/** @deprecated Use createTaskTool({ registry }). */ -export const taskTool = createTaskTool({ registry: subagentRegistry }); - interface SubagentEventBridge { onStarted: () => void; onEvent: (event: LoopEvent) => void; diff --git a/packages/cli/tests/integration/real-api/acp-session-fork-trajectory.test.ts b/packages/cli/tests/integration/real-api/acp-session-fork-trajectory.test.ts index 763f1ea5d..bcc778284 100644 --- a/packages/cli/tests/integration/real-api/acp-session-fork-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/acp-session-fork-trajectory.test.ts @@ -4,7 +4,8 @@ import path from 'node:path'; import * as acp from '@agentclientprotocol/sdk'; import { describe, expect, it, vi } from 'vitest'; import { BladeAgent } from '../../../src/acp/BladeAgent.js'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; + import { PermissionMode, type RuntimeConfig } from '../../../src/config/types.js'; import { HookManager } from '../../../src/hooks/HookManager.js'; import { SkillRegistry } from '../../../src/skills/SkillRegistry.js'; @@ -28,6 +29,8 @@ import { resolveForkQualificationModels, } from './testConfig.js'; +const subagentRegistry = getSubagentRegistry(); + type NotificationPredicate = (notification: acp.SessionNotification) => boolean; interface PendingNotificationWaiter { diff --git a/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts b/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts index 66dcb398f..bdac99aa1 100644 --- a/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts @@ -5,7 +5,8 @@ import * as acp from '@agentclientprotocol/sdk'; import { chromium, type Page } from 'playwright'; import { describe, expect, it } from 'vitest'; import { BladeAgent } from '../../../src/acp/BladeAgent.js'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; + import { ConfigManager } from '../../../src/config/ConfigManager.js'; import { PermissionMode, type RuntimeConfig } from '../../../src/config/types.js'; import { GoalStore } from '../../../src/goals/GoalStore.js'; @@ -25,6 +26,8 @@ import { type TestModelConfig, } from './testConfig.js'; +const subagentRegistry = getSubagentRegistry(); + const enabled = isRealApiTestEnabled(); const models = enabled ? resolveRequiredDeepSeekQualificationModels() : []; diff --git a/packages/cli/tests/integration/real-api/runtime-session-fork-trajectory.test.ts b/packages/cli/tests/integration/real-api/runtime-session-fork-trajectory.test.ts index 13107786a..3190612a5 100644 --- a/packages/cli/tests/integration/real-api/runtime-session-fork-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/runtime-session-fork-trajectory.test.ts @@ -6,7 +6,8 @@ import { Agent } from '../../../src/agent/Agent.js'; import { drainLoop } from '../../../src/agent/loop/index.js'; import type { LoopEvent } from '../../../src/agent/loop/types.js'; import { SessionRuntime } from '../../../src/agent/runtime/SessionRuntime.js'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; + import { PermissionMode, type RuntimeConfig } from '../../../src/config/types.js'; import type { SessionEvent } from '../../../src/context/types.js'; import { HookManager } from '../../../src/hooks/HookManager.js'; @@ -33,6 +34,8 @@ import { resolveForkQualificationModels, } from './testConfig.js'; +const subagentRegistry = getSubagentRegistry(); + const enabled = isRealApiTestEnabled(); const modelConfigs = enabled ? resolveForkQualificationModels(process.env, { requiredDeepSeek: true }) diff --git a/packages/cli/tests/integration/real-api/subagent-resume-trajectory.test.ts b/packages/cli/tests/integration/real-api/subagent-resume-trajectory.test.ts index dfab3806f..ffd74b095 100644 --- a/packages/cli/tests/integration/real-api/subagent-resume-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/subagent-resume-trajectory.test.ts @@ -10,14 +10,14 @@ import { AgentSessionStore, } from '../../../src/agent/subagents/AgentSessionStore.js'; import { BackgroundAgentManager } from '../../../src/agent/subagents/BackgroundAgentManager.js'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; import { BusEventSchema } from '../../../src/api/schemas.js'; import type { RuntimeConfig } from '../../../src/config/types.js'; import { PermissionMode } from '../../../src/config/types.js'; import { SessionRoutes } from '../../../src/server/routes/session.js'; import { SessionService } from '../../../src/services/SessionService.js'; import { getState } from '../../../src/store/vanilla.js'; -import { taskTool } from '../../../src/tools/builtin/task/task.js'; +import { createTaskTool } from '../../../src/tools/builtin/task/task.js'; import { createMockACPClient } from '../../support/mocks/mockACPClient.js'; import { buildRealApiRuntimeConfig, @@ -41,6 +41,8 @@ interface SseCollector { const modelConfigs = isRealApiTestEnabled() ? getEnabledModelConfigs() : []; const enabled = modelConfigs.length > 0; +const subagentRegistry = getSubagentRegistry(); +const taskTool = createTaskTool({ registry: subagentRegistry }); const originalStorageRoot = process.env.BLADE_STORAGE_ROOT; let originalConfig: RuntimeConfig | null = null; diff --git a/packages/cli/tests/integration/real-api/subagent-worktree-trajectory.test.ts b/packages/cli/tests/integration/real-api/subagent-worktree-trajectory.test.ts index ab42e0b6b..c67e41cbd 100644 --- a/packages/cli/tests/integration/real-api/subagent-worktree-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/subagent-worktree-trajectory.test.ts @@ -6,18 +6,20 @@ import { join } from 'pathe'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AgentSessionStore } from '../../../src/agent/subagents/AgentSessionStore.js'; import { BackgroundAgentManager } from '../../../src/agent/subagents/BackgroundAgentManager.js'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; import { PermissionMode } from '../../../src/config/types.js'; import { installWorkspaceSandboxBackendForTests, type WorkspaceSandboxBackend, } from '../../../src/tools/builtin/shell/WorkspaceWriteSandbox.js'; -import { taskTool } from '../../../src/tools/builtin/task/task.js'; +import { createTaskTool } from '../../../src/tools/builtin/task/task.js'; import { taskOutputTool } from '../../../src/tools/builtin/task/taskOutput.js'; import { isRealApiTestEnabled } from './testConfig.js'; const execFileAsync = promisify(execFile); const shouldRun = isRealApiTestEnabled(); +const subagentRegistry = getSubagentRegistry(); +const taskTool = createTaskTool({ registry: subagentRegistry }); async function git(cwd: string, ...args: string[]): Promise { const result = await execFileAsync('git', args, { diff --git a/packages/cli/tests/integration/real-api/subagentResultAdoptionFixture.ts b/packages/cli/tests/integration/real-api/subagentResultAdoptionFixture.ts index af756c89f..43d6bc73a 100644 --- a/packages/cli/tests/integration/real-api/subagentResultAdoptionFixture.ts +++ b/packages/cli/tests/integration/real-api/subagentResultAdoptionFixture.ts @@ -4,9 +4,12 @@ import { AgentSessionStore, } from '../../../src/agent/subagents/AgentSessionStore.js'; import { BackgroundAgentManager } from '../../../src/agent/subagents/BackgroundAgentManager.js'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; import { PermissionMode } from '../../../src/config/types.js'; -import { taskTool } from '../../../src/tools/builtin/task/task.js'; +import { createTaskTool } from '../../../src/tools/builtin/task/task.js'; + +const subagentRegistry = getSubagentRegistry(); +const taskTool = createTaskTool({ registry: subagentRegistry }); export interface SubagentResultAdoptionFixture { sessionId: string; diff --git a/packages/cli/tests/integration/real-api/tui-session-fork-trajectory.test.tsx b/packages/cli/tests/integration/real-api/tui-session-fork-trajectory.test.tsx index c6588946b..f8fc60e92 100644 --- a/packages/cli/tests/integration/real-api/tui-session-fork-trajectory.test.tsx +++ b/packages/cli/tests/integration/real-api/tui-session-fork-trajectory.test.tsx @@ -7,7 +7,8 @@ import { act } from 'react'; import ReactDOM from 'react-dom/client'; import { describe, expect, it, vi } from 'vitest'; import { SessionRuntime } from '../../../src/agent/runtime/SessionRuntime.js'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; + import { PermissionMode, type RuntimeConfig } from '../../../src/config/types.js'; import type { SessionEvent } from '../../../src/context/types.js'; import { HookManager } from '../../../src/hooks/HookManager.js'; @@ -39,6 +40,8 @@ import { resolveForkQualificationModels, } from './testConfig.js'; +const subagentRegistry = getSubagentRegistry(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const enabled = isRealApiTestEnabled(); diff --git a/packages/cli/tests/integration/real-api/tui-subagent-resume-trajectory.test.tsx b/packages/cli/tests/integration/real-api/tui-subagent-resume-trajectory.test.tsx index 7674efd31..e03b29b10 100644 --- a/packages/cli/tests/integration/real-api/tui-subagent-resume-trajectory.test.tsx +++ b/packages/cli/tests/integration/real-api/tui-subagent-resume-trajectory.test.tsx @@ -12,7 +12,8 @@ import { AgentSessionStore, } from '../../../src/agent/subagents/AgentSessionStore.js'; import { BackgroundAgentManager } from '../../../src/agent/subagents/BackgroundAgentManager.js'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; + import type { RuntimeConfig } from '../../../src/config/types.js'; import { PermissionMode } from '../../../src/config/types.js'; import { getState } from '../../../src/store/vanilla.js'; @@ -24,6 +25,8 @@ import { isRealApiTestEnabled, } from './testConfig.js'; +const subagentRegistry = getSubagentRegistry(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const modelConfigs = isRealApiTestEnabled() ? getEnabledModelConfigs() : []; diff --git a/packages/cli/tests/integration/real-api/web-session-trajectory.test.ts b/packages/cli/tests/integration/real-api/web-session-trajectory.test.ts index aea4925bf..a20c4dc95 100644 --- a/packages/cli/tests/integration/real-api/web-session-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/web-session-trajectory.test.ts @@ -4,7 +4,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { SessionRuntime } from '../../../src/agent/runtime/SessionRuntime.js'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; + import { BusEventSchema, ForkSessionResponseSchema, @@ -50,6 +51,8 @@ import { type TestModelConfig, } from './testConfig.js'; +const subagentRegistry = getSubagentRegistry(); + const enabled = isRealApiTestEnabled(); const modelConfigs = enabled ? resolveForkQualificationModels(process.env, { requiredDeepSeek: true }) diff --git a/packages/cli/tests/integration/real-api/web-task-dispatch-trajectory.test.ts b/packages/cli/tests/integration/real-api/web-task-dispatch-trajectory.test.ts index 0572d8cac..ebce02542 100644 --- a/packages/cli/tests/integration/real-api/web-task-dispatch-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/web-task-dispatch-trajectory.test.ts @@ -5,7 +5,8 @@ import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; -import { subagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; +import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; + import { BusEventSchema, CreateTaskResponseSchema, @@ -27,6 +28,8 @@ import { type TestModelConfig, } from './testConfig.js'; +const subagentRegistry = getSubagentRegistry(); + const execFileAsync = promisify(execFile); const enabled = isRealApiTestEnabled(); const modelConfigs = enabled diff --git a/packages/cli/tests/unit/agent-runtime/acp/session.test.ts b/packages/cli/tests/unit/agent-runtime/acp/session.test.ts index 31aba7b75..693bf5642 100644 --- a/packages/cli/tests/unit/agent-runtime/acp/session.test.ts +++ b/packages/cli/tests/unit/agent-runtime/acp/session.test.ts @@ -302,9 +302,7 @@ vi.mock('../../../../src/acp/AcpServiceContext.js', () => ({ destroyRegisteredSession: vi.fn(), destroySession: vi.fn(), setCurrentSession: vi.fn(), - getInstance: vi.fn(() => ({ - getTerminalService: vi.fn(() => terminalState), - })), + getTerminalService: vi.fn(() => terminalState), }, })); diff --git a/packages/cli/tests/unit/agent-runtime/agent/subagent-event-forwarding.test.ts b/packages/cli/tests/unit/agent-runtime/agent/subagent-event-forwarding.test.ts index 52b19d1e4..acab977d4 100644 --- a/packages/cli/tests/unit/agent-runtime/agent/subagent-event-forwarding.test.ts +++ b/packages/cli/tests/unit/agent-runtime/agent/subagent-event-forwarding.test.ts @@ -621,16 +621,20 @@ describe('Task tool subagent event publishing', () => { ); const { PermissionMode } = await import('../../../../src/config/types.js'); - const { subagentRegistry } = await import( + const { getSubagentRegistry } = await import( '../../../../src/agent/subagents/SubagentRegistry.js' ); - subagentRegistry.applyOverrides([ + const registry = getSubagentRegistry(); + registry.applyOverrides([ { name: 'test-worker', description: 'test worker', }, ]); - const { taskTool } = await import('../../../../src/tools/builtin/task/task.js'); + const { createTaskTool } = await import( + '../../../../src/tools/builtin/task/task.js' + ); + const taskTool = createTaskTool({ registry }); const context = { sessionId: 'parent-session', workspaceRoot: '/tmp/parent-workspace', @@ -790,16 +794,20 @@ describe('Task tool subagent event publishing', () => { })); const { PermissionMode } = await import('../../../../src/config/types.js'); - const { subagentRegistry } = await import( + const { getSubagentRegistry } = await import( '../../../../src/agent/subagents/SubagentRegistry.js' ); - subagentRegistry.applyOverrides([ + const registry = getSubagentRegistry(); + registry.applyOverrides([ { name: 'test-worker', description: 'test worker', }, ]); - const { taskTool } = await import('../../../../src/tools/builtin/task/task.js'); + const { createTaskTool } = await import( + '../../../../src/tools/builtin/task/task.js' + ); + const taskTool = createTaskTool({ registry }); const notifyBackgroundSubagentCompleted = vi.fn(async (agentId: string) => { completionOrder.push(`notify:${agentId}`); }); diff --git a/packages/cli/tests/unit/agent-runtime/agent/task-resume.test.ts b/packages/cli/tests/unit/agent-runtime/agent/task-resume.test.ts index c29812528..8ab7fb724 100644 --- a/packages/cli/tests/unit/agent-runtime/agent/task-resume.test.ts +++ b/packages/cli/tests/unit/agent-runtime/agent/task-resume.test.ts @@ -102,9 +102,10 @@ describe('Task durable subagent resume protocol', () => { previousStorageRoot = process.env.BLADE_STORAGE_ROOT; storageRoot = mkdtempSync(path.join(os.tmpdir(), 'blade-task-resume-')); process.env.BLADE_STORAGE_ROOT = storageRoot; - const { subagentRegistry } = await import( + const { getSubagentRegistry } = await import( '../../../../src/agent/subagents/SubagentRegistry.js' ); + const subagentRegistry = getSubagentRegistry(); subagentRegistry.clear(); subagentRegistry.register({ name: 'durable-reviewer', @@ -144,7 +145,10 @@ describe('Task durable subagent resume protocol', () => { }); async function taskTool() { - return (await import('../../../../src/tools/builtin/task/task.js')).taskTool; + const { createTaskTool } = await import( + '../../../../src/tools/builtin/task/task.js' + ); + return createTaskTool(); } async function resetProcessState() { @@ -176,9 +180,10 @@ describe('Task durable subagent resume protocol', () => { const { createTaskTool } = await import( '../../../../src/tools/builtin/task/task.js' ); - const { subagentRegistry } = await import( + const { getSubagentRegistry } = await import( '../../../../src/agent/subagents/SubagentRegistry.js' ); + const subagentRegistry = getSubagentRegistry(); const agentResources = { projectRoot: workspaceA, subagents: subagentRegistry, diff --git a/packages/cli/tests/unit/tooling/tools/builtin/team-tools.test.ts b/packages/cli/tests/unit/tooling/tools/builtin/team-tools.test.ts index 429b7694a..24c47fdf7 100644 --- a/packages/cli/tests/unit/tooling/tools/builtin/team-tools.test.ts +++ b/packages/cli/tests/unit/tooling/tools/builtin/team-tools.test.ts @@ -65,7 +65,8 @@ vi.mock('../../../../../src/agent/subagents/BackgroundAgentManager', () => ({ }, })); -import { subagentRegistry } from '../../../../../src/agent/subagents/SubagentRegistry'; +import { getSubagentRegistry } from '../../../../../src/agent/subagents/SubagentRegistry'; + import { TeamMailbox } from '../../../../../src/agent/teams/TeamMailbox'; import { TeamStore } from '../../../../../src/agent/teams/TeamStore'; import { TeamTaskGraph } from '../../../../../src/agent/teams/TeamTaskGraph'; @@ -75,6 +76,8 @@ import { createTeamTools } from '../../../../../src/tools/builtin/team/index'; import { executeToolInvocation } from '../../../../../src/tools/execution/ToolInvocationRunner'; import type { Tool } from '../../../../../src/tools/types'; +const subagentRegistry = getSubagentRegistry(); + type TeamToolOptions = NonNullable[0]>; async function createTempConfigDir() { From db31cb0c75ba5568c741047428492916f24876eb Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:24:53 +0800 Subject: [PATCH 10/59] refactor(test): make shell safety cases table driven --- .../utils/shell/readOnlyValidation.test.ts | 570 +++++------------- 1 file changed, 152 insertions(+), 418 deletions(-) diff --git a/packages/cli/tests/unit/platform/utils/shell/readOnlyValidation.test.ts b/packages/cli/tests/unit/platform/utils/shell/readOnlyValidation.test.ts index d044ff5ab..06351d39d 100644 --- a/packages/cli/tests/unit/platform/utils/shell/readOnlyValidation.test.ts +++ b/packages/cli/tests/unit/platform/utils/shell/readOnlyValidation.test.ts @@ -6,274 +6,96 @@ import { validateFlags, } from '../../../../../src/utils/shell/readOnlyValidation.js'; -// ============================================================ -// isReadOnlyBashCommand (main entry point) -// ============================================================ +function assertCommands( + predicate: (command: string) => boolean, + expected: boolean, + commands: readonly string[] +): void { + for (const command of commands) expect(predicate(command), command).toBe(expected); +} describe('isReadOnlyBashCommand', () => { - // --- Git read-only commands --- - - it('allows basic git status', () => { - expect(isReadOnlyBashCommand('git status')).toBe(true); - }); - - it('allows git status with flags', () => { - expect(isReadOnlyBashCommand('git status --short -b')).toBe(true); - }); - - it('allows git log with flags', () => { - expect(isReadOnlyBashCommand('git log --oneline -5')).toBe(true); - expect(isReadOnlyBashCommand('git log --all --graph --oneline')).toBe(true); - }); - - it('allows git diff', () => { - expect(isReadOnlyBashCommand('git diff --cached')).toBe(true); - expect(isReadOnlyBashCommand('git diff --stat')).toBe(true); - }); - - it('allows git show', () => { - expect(isReadOnlyBashCommand('git show HEAD')).toBe(true); - }); - - it('allows git blame', () => { - expect(isReadOnlyBashCommand('git blame src/index.ts')).toBe(true); - }); - - it('allows git branch --list', () => { - expect(isReadOnlyBashCommand('git branch -a')).toBe(true); - expect(isReadOnlyBashCommand('git branch --list')).toBe(true); - expect(isReadOnlyBashCommand('git branch -v')).toBe(true); - expect(isReadOnlyBashCommand('git branch -r')).toBe(true); - }); - - it('allows git tag --list', () => { - expect(isReadOnlyBashCommand('git tag -l')).toBe(true); - expect(isReadOnlyBashCommand('git tag --list')).toBe(true); - }); - - it('allows git remote -v', () => { - expect(isReadOnlyBashCommand('git remote -v')).toBe(true); - expect(isReadOnlyBashCommand('git remote')).toBe(true); - }); - - it('allows git rev-parse', () => { - expect(isReadOnlyBashCommand('git rev-parse HEAD')).toBe(true); - expect(isReadOnlyBashCommand('git rev-parse --show-toplevel')).toBe(true); - }); - - it('allows git ls-files', () => { - expect(isReadOnlyBashCommand('git ls-files')).toBe(true); - }); - - it('allows git stash list/show', () => { - expect(isReadOnlyBashCommand('git stash list')).toBe(true); - expect(isReadOnlyBashCommand('git stash show')).toBe(true); - }); - - it('allows git describe', () => { - expect(isReadOnlyBashCommand('git describe --tags')).toBe(true); - }); - - it('allows git merge-base', () => { - expect(isReadOnlyBashCommand('git merge-base main HEAD')).toBe(true); - }); - - it('allows git worktree list', () => { - expect(isReadOnlyBashCommand('git worktree list')).toBe(true); - }); - - it('allows git reflog', () => { - expect(isReadOnlyBashCommand('git reflog')).toBe(true); - }); - - it('allows git grep', () => { - expect(isReadOnlyBashCommand('git grep -n pattern')).toBe(true); - }); - - // --- Git with normalization --- - - it('allows git with -C (normalized away)', () => { - // -C is stripped by normalizeGitCommand which is in commandNormalizer - // but isReadOnlyBashCommand doesn't call normalizeGitCommand directly - // The flag parsing handles -C as a known git diff flag - // The PermissionChecker normalization handles the -C case - expect(isReadOnlyBashCommand('git status')).toBe(true); - }); - - it('allows git with safe env var prefix', () => { - expect(isReadOnlyBashCommand('NODE_ENV=production git status')).toBe(true); - }); - - it('rejects git with executable env var prefix (GIT_PAGER runs external binary)', () => { - // GIT_PAGER is not in SAFE_ENV_VARS — it executes external binaries - // After stripping, the env var remains, containsUnsafePatterns won't flag it, - // but the env var prefix makes it not match any known command pattern - expect(isReadOnlyBashCommand('GIT_PAGER=/tmp/evil git log')).toBe(false); - expect(isReadOnlyBashCommand('GIT_SSH_COMMAND=/tmp/evil git ls-remote')).toBe( - false - ); - }); - - it('allows git with timeout wrapper', () => { - expect(isReadOnlyBashCommand('timeout 10 git diff')).toBe(true); - }); - - it('allows compound git readonly commands', () => { - expect(isReadOnlyBashCommand('git status && git log --oneline -5')).toBe(true); - }); - - // --- Git write commands (should reject) --- - - it('rejects git commit', () => { - expect(isReadOnlyBashCommand('git commit -m "msg"')).toBe(false); - }); - - it('rejects git push', () => { - expect(isReadOnlyBashCommand('git push origin main')).toBe(false); - }); - - it('rejects git checkout -b (create branch)', () => { - expect(isReadOnlyBashCommand('git checkout -b feature')).toBe(false); - }); - - it('rejects git branch new-branch (create branch)', () => { - // git branch with positional arg and no --list flag = creating a branch - expect(isReadOnlyBashCommand('git branch new-branch')).toBe(false); - }); - - it('rejects git tag v1.0 (create tag)', () => { - // git tag without -l and with positional args = creating a tag - expect(isReadOnlyBashCommand('git tag v1.0')).toBe(false); - }); - - it('rejects git stash (without list/show)', () => { - expect(isReadOnlyBashCommand('git stash')).toBe(false); - }); - - it('rejects git reset', () => { - expect(isReadOnlyBashCommand('git reset --hard')).toBe(false); - }); - - it('rejects git rebase', () => { - expect(isReadOnlyBashCommand('git rebase main')).toBe(false); - }); - - it('rejects git merge', () => { - expect(isReadOnlyBashCommand('git merge feature')).toBe(false); - }); - - // --- Security hardening --- - - it('rejects pipe commands', () => { - expect(isReadOnlyBashCommand('git log | rm -rf /')).toBe(false); - expect(isReadOnlyBashCommand('git log | head -5')).toBe(false); - }); - - it('rejects redirect commands', () => { - expect(isReadOnlyBashCommand('git log > /tmp/out')).toBe(false); - }); - - it('rejects cd + git compound (sandbox escape)', () => { - expect(isReadOnlyBashCommand('cd /tmp && git status')).toBe(false); - }); - - it('rejects $variable expansion', () => { - expect(isReadOnlyBashCommand('git diff "$EVIL"')).toBe(false); - }); - - it('rejects backtick subshell', () => { - expect(isReadOnlyBashCommand('git log `cat /etc/passwd`')).toBe(false); - }); - - // --- Non-git read-only commands --- - - it('allows cat', () => { - expect(isReadOnlyBashCommand('cat file.txt')).toBe(true); - }); - - it('allows wc', () => { - expect(isReadOnlyBashCommand('wc -l file.txt')).toBe(true); - }); - - it('allows head/tail', () => { - expect(isReadOnlyBashCommand('head -20 file.txt')).toBe(true); - expect(isReadOnlyBashCommand('tail -10 file.txt')).toBe(true); - }); - - it('allows ls', () => { - expect(isReadOnlyBashCommand('ls -la')).toBe(true); - expect(isReadOnlyBashCommand('ls -la /some/dir')).toBe(true); - }); - - it('allows pwd', () => { - expect(isReadOnlyBashCommand('pwd')).toBe(true); - }); - - it('allows whoami', () => { - expect(isReadOnlyBashCommand('whoami')).toBe(true); - }); - - it('allows find (without -delete/-exec)', () => { - expect(isReadOnlyBashCommand('find . -name "*.ts"')).toBe(true); - }); - - it('rejects find -delete', () => { - expect(isReadOnlyBashCommand('find . -name "*.tmp" -delete')).toBe(false); - }); - - it('rejects find -exec', () => { - expect(isReadOnlyBashCommand('find . -name "*.ts" -exec rm {} ;')).toBe(false); - }); - - it('allows rg with flags', () => { - expect(isReadOnlyBashCommand('rg -n pattern src/')).toBe(true); - expect(isReadOnlyBashCommand('rg -i --type ts pattern')).toBe(true); - }); - - it('allows grep with flags', () => { - expect(isReadOnlyBashCommand('grep -rn pattern .')).toBe(true); - }); - - // --- gh read-only commands --- - - it('allows gh pr list', () => { - expect(isReadOnlyBashCommand('gh pr list')).toBe(true); - }); - - it('allows gh pr view', () => { - expect(isReadOnlyBashCommand('gh pr view 123')).toBe(true); - }); - - it('allows gh issue list', () => { - expect(isReadOnlyBashCommand('gh issue list --state open')).toBe(true); - }); - - it('allows gh run view', () => { - expect(isReadOnlyBashCommand('gh run view 12345 --log')).toBe(true); - }); - - // --- Edge cases --- - - it('rejects empty command', () => { - expect(isReadOnlyBashCommand('')).toBe(false); - expect(isReadOnlyBashCommand(' ')).toBe(false); - }); - - it('allows compound safe commands', () => { - expect(isReadOnlyBashCommand('pwd && ls -la')).toBe(true); - expect(isReadOnlyBashCommand('cat file.txt && wc -l file.txt')).toBe(true); - }); - - it('rejects compound with unsafe command', () => { - expect(isReadOnlyBashCommand('git status && rm -rf /')).toBe(false); - expect(isReadOnlyBashCommand('ls -la && git push')).toBe(false); + it('accepts read-only commands and supported wrappers', () => { + assertCommands(isReadOnlyBashCommand, true, [ + 'git status', + 'git status --short -b', + 'git log --oneline -5', + 'git log --all --graph --oneline', + 'git diff --cached', + 'git diff --stat', + 'git show HEAD', + 'git blame src/index.ts', + 'git branch -a', + 'git branch --list', + 'git branch -v', + 'git branch -r', + 'git tag -l', + 'git tag --list', + 'git remote -v', + 'git remote', + 'git rev-parse HEAD', + 'git rev-parse --show-toplevel', + 'git ls-files', + 'git stash list', + 'git stash show', + 'git describe --tags', + 'git merge-base main HEAD', + 'git worktree list', + 'git reflog', + 'git grep -n pattern', + 'NODE_ENV=production git status', + 'timeout 10 git diff', + 'git status && git log --oneline -5', + 'cat file.txt', + 'wc -l file.txt', + 'head -20 file.txt', + 'tail -10 file.txt', + 'ls -la', + 'ls -la /some/dir', + 'pwd', + 'whoami', + 'find . -name "*.ts"', + 'rg -n pattern src/', + 'rg -i --type ts pattern', + 'grep -rn pattern .', + 'gh pr list', + 'gh pr view 123', + 'gh issue list --state open', + 'gh run view 12345 --log', + 'pwd && ls -la', + 'cat file.txt && wc -l file.txt', + ]); + }); + + it('rejects mutating commands and unsafe shell syntax', () => { + assertCommands(isReadOnlyBashCommand, false, [ + 'GIT_PAGER=/tmp/evil git log', + 'GIT_SSH_COMMAND=/tmp/evil git ls-remote', + 'git commit -m "msg"', + 'git push origin main', + 'git checkout -b feature', + 'git branch new-branch', + 'git tag v1.0', + 'git stash', + 'git reset --hard', + 'git rebase main', + 'git merge feature', + 'git log | rm -rf /', + 'git log | head -5', + 'git log > /tmp/out', + 'cd /tmp && git status', + 'git diff "$EVIL"', + 'git log `cat /etc/passwd`', + 'find . -name "*.tmp" -delete', + 'find . -name "*.ts" -exec rm {} ;', + '', + ' ', + 'git status && rm -rf /', + 'ls -la && git push', + ]); }); }); -// ============================================================ -// validateFlags -// ============================================================ - describe('validateFlags', () => { const config = { safeFlags: { @@ -285,168 +107,80 @@ describe('validateFlags', () => { }, }; - it('accepts known safe flags', () => { - expect(validateFlags(['--oneline', '--all'], 0, config)).toBe(true); - }); - - it('rejects unknown flags', () => { - expect(validateFlags(['--unknown'], 0, config)).toBe(false); - }); - - it('handles --flag=value', () => { - expect(validateFlags(['--format=short'], 0, config)).toBe(true); - expect(validateFlags(['--oneline=bad'], 0, config)).toBe(false); // none type with = is invalid - }); - - it('handles -n 5 (number arg)', () => { - expect(validateFlags(['-n', '5'], 0, config)).toBe(true); - expect(validateFlags(['-n', 'abc'], 0, config)).toBe(false); - }); - - it('handles positional args (skipped)', () => { - expect(validateFlags(['HEAD', '--oneline'], 0, config)).toBe(true); - }); - - it('handles -- separator', () => { - expect( - validateFlags(['--oneline', '--', '--unknown-but-positional'], 0, config) - ).toBe(true); - }); - - it('handles combined short flags', () => { - // -v is none, combined flags must all be none - const config2 = { - safeFlags: { - '-v': 'none' as const, - '-a': 'none' as const, - }, - }; - expect(validateFlags(['-va'], 0, config2)).toBe(true); - }); - - it('rejects combined flags with non-none types', () => { - const config2 = { - safeFlags: { - '-v': 'none' as const, - '-n': 'number' as const, - }, - }; - expect(validateFlags(['-vn'], 0, config2)).toBe(false); - }); - - it('handles git numeric shorthand -5', () => { - expect(validateFlags(['-5'], 0, config)).toBe(true); - expect(validateFlags(['-10'], 0, config)).toBe(true); - }); - - it('handles startIndex', () => { - expect(validateFlags(['git', 'log', '--oneline'], 2, config)).toBe(true); + it.each([ + [['--oneline', '--all'], 0, true], + [['--unknown'], 0, false], + [['--format=short'], 0, true], + [['--oneline=bad'], 0, false], + [['-n', '5'], 0, true], + [['-n', 'abc'], 0, false], + [['HEAD', '--oneline'], 0, true], + [['--oneline', '--', '--unknown-but-positional'], 0, true], + [['-5'], 0, true], + [['-10'], 0, true], + [['git', 'log', '--oneline'], 2, true], + ] as const)('validates %j from index %d as %s', (args, startIndex, expected) => { + expect(validateFlags([...args], startIndex, config)).toBe(expected); + }); + + it.each([ + [{ '-v': 'none', '-a': 'none' }, true], + [{ '-v': 'none', '-n': 'number' }, false], + ] as const)('validates combined flags in %j as %s', (safeFlags, expected) => { + expect(validateFlags(['-va'], 0, { safeFlags })).toBe(expected); }); }); -// ============================================================ -// isCommandSafeViaFlagParsing -// ============================================================ - describe('isCommandSafeViaFlagParsing', () => { - it('validates git log with known flags', () => { - expect(isCommandSafeViaFlagParsing('git log --oneline -5')).toBe(true); - expect(isCommandSafeViaFlagParsing('git log --all --graph --oneline')).toBe(true); - }); - - it('rejects git log with $ in args', () => { - expect(isCommandSafeViaFlagParsing('git log $HOME')).toBe(false); - }); - - it('rejects brace expansion in args', () => { - expect(isCommandSafeViaFlagParsing('git log {main,dev}')).toBe(false); - }); - - it('validates rg with flags', () => { - expect(isCommandSafeViaFlagParsing('rg -n pattern src/')).toBe(true); - }); - - it('validates gh pr list', () => { - expect(isCommandSafeViaFlagParsing('gh pr list --state open')).toBe(true); - }); - - it('rejects unknown commands', () => { - expect(isCommandSafeViaFlagParsing('npm install')).toBe(false); - expect(isCommandSafeViaFlagParsing('curl http://evil.com')).toBe(false); - }); - - it('validates git branch -a (list mode)', () => { - expect(isCommandSafeViaFlagParsing('git branch -a')).toBe(true); - }); - - it('rejects git branch new-branch (create mode)', () => { - expect(isCommandSafeViaFlagParsing('git branch new-branch')).toBe(false); - }); - - it('rejects git tag v1.0 (create mode)', () => { - expect(isCommandSafeViaFlagParsing('git tag v1.0')).toBe(false); - }); - - it('validates git tag -l', () => { - expect(isCommandSafeViaFlagParsing('git tag -l')).toBe(true); - }); - - it('rejects git reflog expire', () => { - expect(isCommandSafeViaFlagParsing('git reflog expire')).toBe(false); - }); - - it('rejects git reflog delete', () => { - expect(isCommandSafeViaFlagParsing('git reflog delete')).toBe(false); - }); - - it('validates git remote -v', () => { - expect(isCommandSafeViaFlagParsing('git remote -v')).toBe(true); - }); - - it('rejects git remote add', () => { - expect(isCommandSafeViaFlagParsing('git remote add origin url')).toBe(false); - }); - - it('rejects gh auth status --show-token', () => { - expect(isCommandSafeViaFlagParsing('gh auth status --show-token')).toBe(false); + it('accepts supported flag combinations', () => { + assertCommands(isCommandSafeViaFlagParsing, true, [ + 'git log --oneline -5', + 'git log --all --graph --oneline', + 'rg -n pattern src/', + 'gh pr list --state open', + 'git branch -a', + 'git tag -l', + 'git remote -v', + ]); + }); + + it('rejects unsafe arguments, mutations, and unknown commands', () => { + assertCommands(isCommandSafeViaFlagParsing, false, [ + 'git log $HOME', + 'git log {main,dev}', + 'npm install', + 'curl http://evil.com', + 'git branch new-branch', + 'git tag v1.0', + 'git reflog expire', + 'git reflog delete', + 'git remote add origin url', + 'gh auth status --show-token', + ]); }); }); -// ============================================================ -// isCommandReadOnly -// ============================================================ - describe('isCommandReadOnly', () => { - it('accepts simple regex commands', () => { - expect(isCommandReadOnly('cat file.txt')).toBe(true); - expect(isCommandReadOnly('head -20 file.txt')).toBe(true); - expect(isCommandReadOnly('wc -l file.txt')).toBe(true); - expect(isCommandReadOnly('diff a.txt b.txt')).toBe(true); - }); - - it('accepts custom regex commands', () => { - expect(isCommandReadOnly('pwd')).toBe(true); - expect(isCommandReadOnly('whoami')).toBe(true); - expect(isCommandReadOnly('node --version')).toBe(true); - }); - - it('accepts flag-validated commands', () => { - expect(isCommandReadOnly('git log --oneline -5')).toBe(true); - expect(isCommandReadOnly('rg -n pattern src/')).toBe(true); - }); - - it('rejects commands with variable expansion', () => { - expect(isCommandReadOnly('echo $HOME')).toBe(false); - expect(isCommandReadOnly('cat ${FILE}')).toBe(false); - }); - - it('strips trailing 2>&1', () => { - expect(isCommandReadOnly('git status 2>&1')).toBe(true); - }); - - it('rejects dangerous git options via post-regex check', () => { - // find would match via regex but git -c should be blocked - // This is a defense-in-depth check - expect(isCommandReadOnly('git -c core.pager=less log')).toBe(false); + it('accepts commands from every read-only validation tier', () => { + assertCommands(isCommandReadOnly, true, [ + 'cat file.txt', + 'head -20 file.txt', + 'wc -l file.txt', + 'diff a.txt b.txt', + 'pwd', + 'whoami', + 'node --version', + 'git log --oneline -5', + 'rg -n pattern src/', + 'git status 2>&1', + ]); + }); + + it('rejects expansion and dangerous git configuration', () => { + assertCommands(isCommandReadOnly, false, [ + 'echo $HOME', + 'cat ${FILE}', + 'git -c core.pager=less log', + ]); }); }); From b3b1875bc257a56dc0bf79209fdbc0460ce7e59a Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:26:55 +0800 Subject: [PATCH 11/59] docs: define codebase simplification budget --- docs/design/codebase-simplification.md | 80 ++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/design/codebase-simplification.md diff --git a/docs/design/codebase-simplification.md b/docs/design/codebase-simplification.md new file mode 100644 index 000000000..e9bcfb9fc --- /dev/null +++ b/docs/design/codebase-simplification.md @@ -0,0 +1,80 @@ +# Codebase Simplification + +## Objective + +Reduce tracked TypeScript/TSX/JavaScript LOC by at least 30% while preserving +supported behavior, keeping coverage above repository thresholds, and making +runtime ownership easier to follow. + +Baseline at `8bcd2696`: + +- Total source and test LOC: 595,280 +- Production LOC: 248,570 +- Test LOC: 342,035 +- Target maximum: 416,696 LOC +- Required net reduction: 178,584 LOC + +The metric excludes generated build output, dependencies, Markdown, JSON, and +lockfiles. Deleting tests without equivalent coverage, compressing formatting, +or moving code into uncounted formats does not count as simplification. + +## Guardrails + +- Preserve CLI, Web, ACP, MCP, browser, LSP, task, team, goal, and provider + behavior unless a surface is already unused or explicitly deprecated. +- Do not regress the measured unit-test baseline: 68.20% statements, 62.48% + branches, 69.97% functions, and 69.54% lines. The Vitest config declares 80% + thresholds, but the current project runner does not enforce them. +- Keep one authoritative state machine per behavior. Surfaces project state; + they do not reimplement it. +- Remove deprecated compatibility paths instead of retaining aliases. +- Prefer data-driven routing and named boundaries over nested conditionals. +- Keep each independently verifiable reduction in its own commit. +- Do not use git worktrees. + +## Delivery Tracks + +### 1. Dead Code And Compatibility + +- Remove unreachable files and unused dependencies reported by Knip. +- Remove unused exports and test-only production helpers. +- Remove deprecated singleton and forwarding APIs after migrating consumers. + +### 2. Runtime Unification + +- Collapse local and remote Session metadata/event construction. +- Share fork, archive, lifecycle, and error-mapping primitives. +- Reduce duplicate state transitions across Web, ACP, Headless, and TUI. + +### 3. God File Decomposition + +- Split route registration from Session route behavior. +- Split provider/tool execution phases out of the Agent loop. +- Split Session catalog, mutation, fork/rewind, and projection responsibilities. +- Split Web store orchestration from pure event reducers. + +Decomposition is accepted only when dependencies become narrower; moving the +same code without simplifying ownership does not count toward the LOC target. + +### 4. Test Compression + +- Replace repeated scenario bodies with typed table-driven cases. +- Share process, ACP, PTY, Web, and Provider harnesses. +- Remove implementation-only compatibility tests together with deleted APIs. +- Preserve unique assertions and real-boundary qualification coverage. + +## Progress + +| Commit | Change | Net LOC | +| --- | --- | ---: | +| `84ba35f2` | Remove unused child abort controller | -72 | +| `8bcd2696` | Consolidate subagent delegation dependencies | +24 | +| `8b2d4ea0` | Remove unused subsystems and dependencies | -1,608 | +| `d5beaa46` | Remove obsolete compatibility and context layers | -1,362 | +| `d5373627` | Unify Session metadata update construction | -104 | +| `e7007472` | Share local and remote fork projection | -122 | +| `0efea96c` | Deduplicate shared theme tokens | -374 | +| `99d2dee4` | Encode read-only flags declaratively | -557 | +| `32e7cb0a` | Remove deprecated global subagent tool registry | +29 | +| `db31cb0c` | Make shell safety cases table driven | -266 | +| **Current total** | | **-4,411** | From b6a20834c42c237aec735763edcc8873abb2fc5f Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:34:00 +0800 Subject: [PATCH 12/59] refactor(test): unify provider recovery trajectories --- ...round-provider-recovery-trajectory.test.ts | 1215 +-------------- ...der-rate-limit-cooldown-trajectory.test.ts | 1260 +-------------- .../providerRecoveryTrajectoryHarness.ts | 1371 +++++++++++++++++ ...ound-provider-recovery-search-gate.test.ts | 17 +- .../tests/unit/scripts/qualification.test.ts | 13 +- 5 files changed, 1395 insertions(+), 2481 deletions(-) create mode 100644 packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts diff --git a/packages/cli/tests/integration/real-api/foreground-provider-recovery-trajectory.test.ts b/packages/cli/tests/integration/real-api/foreground-provider-recovery-trajectory.test.ts index 927b0e1a7..fbda88397 100644 --- a/packages/cli/tests/integration/real-api/foreground-provider-recovery-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/foreground-provider-recovery-trajectory.test.ts @@ -1,1214 +1,3 @@ -import { type ChildProcess, execFile, spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, realpath, writeFile } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import type { AddressInfo } from 'node:net'; -import { createServer as createNetServer } from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { type Browser, chromium, type Page } from 'playwright'; -import { describe, expect, it } from 'vitest'; -import { - type ProcessIdentity, - processIdentityMatches, -} from '../../../src/utils/process/ProcessIdentity.js'; -import { - createSplitPtyMarkerInstruction, - isCompleteRawPtyMarkerEvidence, -} from '../../support/foregroundBoundedOutputPtyDriver.js'; -import { removeTestDirectory } from '../../support/helpers/removeTestDirectory.js'; -import { - assertNoSecrets, - finalAssistantText, - findSessionTranscript, - readSessionEvents, -} from './sessionForkTrajectoryHarness.js'; -import { - buildRealApiRuntimeConfig, - isRealApiTestEnabled, - isReleaseMatrix, - releaseBlockingSurfaces, - resolveRequiredDeepSeekQualificationModels, - type TestModelConfig, -} from './testConfig.js'; +import { defineProviderRecoveryTrajectory } from './providerRecoveryTrajectoryHarness.js'; -const execFileAsync = promisify(execFile); -const surfaces = releaseBlockingSurfaces(['headless', 'acp', 'pty', 'web'] as const); -const models = isRealApiTestEnabled() - ? resolveRequiredDeepSeekQualificationModels() - : []; -const matrix = models.flatMap((model) => - surfaces.map((surface) => ({ model, surface })) -); -if (isRealApiTestEnabled() && matrix.length !== (isReleaseMatrix() ? 6 : 8)) { - throw new Error( - `Foreground Provider recovery matrix must contain ${isReleaseMatrix() ? 6 : 8} cells, got ${matrix.length}` - ); -} - -const cliEntry = path.resolve(import.meta.dirname, '../../../dist/blade.js'); -const acpRunner = path.resolve( - import.meta.dirname, - '../../support/foregroundProviderRecoveryAcpRunner.ts' -); -const ptyRunner = path.resolve( - import.meta.dirname, - '../../support/foregroundProviderRecoveryPtyRunner.ts' -); -const MAX_CAPTURE_CHARS = 256_000; -const INJECTED_FAILURES = 4; -const CIRCUIT_OPEN_MS = 2_000; - -interface SurfaceEvidence { - sessionId: string; - output: string; - protocolOutput?: string; - finalMarkerSeen?: boolean; - secretSeen?: boolean; - processes?: Array<{ pid: number; identity: ProcessIdentity }>; - terminalReleaseCount?: number; - secondarySessionId?: string; - secondarySubmittedAt?: number; - providerProbeCount?: number; - sawBoundedForegroundRecovery?: boolean; - sawProviderLifecycle?: boolean; - sawProviderRecovery?: boolean; - sawProviderRecoveryWait?: boolean; - sawProviderRecoveryProbe?: boolean; -} - -interface SessionEventProbe { - events: Array<{ type: string; properties: Record }>; - close(): Promise; -} - -function safeSlug(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -function appendTail(current: string, chunk: Buffer | string): string { - return `${current}${chunk.toString()}`.slice(-MAX_CAPTURE_CHARS); -} - -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 120_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Provider recovery surface process did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -function upstreamUrl(baseUrl: string, requestUrl: string | undefined): URL { - const target = new URL(baseUrl); - const incoming = new URL(requestUrl ?? '/', 'http://127.0.0.1'); - target.pathname = `${target.pathname.replace(/\/$/, '')}/${incoming.pathname.replace( - /^\//, - '' - )}`; - target.search = incoming.search; - return target; -} - -async function readRequestBody( - request: import('node:http').IncomingMessage -): Promise { - const chunks: Buffer[] = []; - for await (const chunk of request) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks); -} - -async function startRecoveryProxy(baseUrl: string) { - let requestCount = 0; - let injectedFailures = 0; - let forwardedRequests = 0; - let circuitOpenedAt: number | undefined; - const requestStartedAt: number[] = []; - const privateBodyMarker = 'PRIVATE_FOREGROUND_RECOVERY_BODY_MUST_NOT_SURFACE'; - const server = createServer((request, response) => { - void (async () => { - const body = await readRequestBody(request); - requestCount++; - requestStartedAt.push(Date.now()); - if (injectedFailures < INJECTED_FAILURES) { - injectedFailures++; - response.writeHead(503, { - 'content-type': 'application/json', - 'retry-after-ms': injectedFailures === INJECTED_FAILURES ? '2000' : '0', - }); - response.end( - JSON.stringify({ - error: { - type: 'server_error', - message: privateBodyMarker, - }, - }) - ); - if (injectedFailures === INJECTED_FAILURES) { - circuitOpenedAt = Date.now(); - } - return; - } - - forwardedRequests++; - const headers = new Headers(); - for (const [name, value] of Object.entries(request.headers)) { - if (!value || name === 'host' || name === 'content-length') continue; - headers.set(name, Array.isArray(value) ? value.join(', ') : value); - } - const upstream = await fetch(upstreamUrl(baseUrl, request.url), { - method: request.method ?? 'POST', - headers, - body: - request.method === 'GET' || request.method === 'HEAD' - ? undefined - : body.toString('utf8'), - redirect: 'manual', - }); - const responseHeaders: Record = {}; - upstream.headers.forEach((value, name) => { - if ( - ![ - 'connection', - 'content-encoding', - 'content-length', - 'transfer-encoding', - ].includes(name) - ) { - responseHeaders[name] = value; - } - }); - response.writeHead(upstream.status, responseHeaders); - if (upstream.body) { - const reader = upstream.body.getReader(); - for (;;) { - const chunk = await reader.read(); - if (chunk.done) break; - response.write(Buffer.from(chunk.value)); - } - } - response.end(); - })().catch((error: unknown) => { - if (response.headersSent) { - response.destroy(error instanceof Error ? error : undefined); - return; - } - response.writeHead(502, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ error: { type: 'proxy_error' } })); - }); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - server.off('error', reject); - resolve(); - }); - }); - const address = server.address() as AddressInfo; - return { - baseURL: `http://127.0.0.1:${address.port}`, - privateBodyMarker, - requestCount: () => requestCount, - injectedFailures: () => injectedFailures, - forwardedRequests: () => forwardedRequests, - circuitOpenedAt: () => circuitOpenedAt, - requestStartedAt: () => [...requestStartedAt], - close: async () => { - server.closeAllConnections(); - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - }, - }; -} - -async function initializeWorkspace(workspace: string): Promise { - await Promise.all([ - mkdir(path.join(workspace, 'src'), { recursive: true }), - mkdir(path.join(workspace, 'test'), { recursive: true }), - ]); - await writeFile( - path.join(workspace, 'package.json'), - `${JSON.stringify( - { - name: 'blade-foreground-provider-recovery', - private: true, - type: 'module', - scripts: { test: 'node --test' }, - }, - null, - 2 - )}\n` - ); - await writeFile( - path.join(workspace, 'src', 'add.js'), - 'export function add(left, right) {\n return left - right;\n}\n' - ); - await writeFile( - path.join(workspace, 'test', 'add.test.js'), - [ - "import assert from 'node:assert/strict';", - "import test from 'node:test';", - "import { add } from '../src/add.js';", - '', - "test('adds two numbers', () => {", - ' assert.equal(add(4, 3), 7);', - '});', - '', - ].join('\n') - ); - await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); - await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, - }); - await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, - }); - await execFileAsync('git', ['add', '.'], { cwd: workspace }); - await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); - return realpath(workspace); -} - -async function writeRuntimeConfig( - home: string, - model: TestModelConfig, - baseURL: string -): Promise { - const config = buildRealApiRuntimeConfig({ ...model, baseURL }); - const modelsWithoutRetryOverride = config.models.map((entry) => { - const overrides = { ...entry.overrides }; - delete overrides.maxRetries; - return { ...entry, overrides }; - }); - const bladeHome = path.join(home, '.blade'); - const skillCreator = path.join(bladeHome, 'skills', 'skill-creator'); - await Promise.all([ - mkdir(bladeHome, { recursive: true }), - mkdir(skillCreator, { recursive: true }), - ]); - await Promise.all([ - writeFile( - path.join(bladeHome, 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: modelsWithoutRetryOverride, - modelProviders: config.modelProviders, - permissionMode: 'yolo', - providerForegroundRecoveryMs: 120_000, - providerCircuitBreakerOpenMs: CIRCUIT_OPEN_MS, - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - null, - 2 - )}\n`, - { mode: 0o600 } - ), - writeFile( - path.join(skillCreator, 'SKILL.md'), - [ - '---', - 'name: skill-creator', - 'description: Local deterministic qualification fixture.', - '---', - '', - '# Fixture Skill Creator', - '', - ].join('\n') - ), - ]); -} - -function childEnvironment(home: string, storageRoot: string): NodeJS.ProcessEnv { - return { - ...process.env, - HOME: home, - BLADE_STORAGE_ROOT: storageRoot, - BLADE_AUTO_MEMORY: '0', - BLADE_TELEMETRY_DISABLED: '1', - TERM: 'xterm-256color', - }; -} - -function parseHeadlessContent(stdout: string): string { - return stdout - .split(/\r?\n/) - .filter(Boolean) - .flatMap((line) => { - try { - const event = JSON.parse(line) as Record; - return event.type === 'content_delta' && typeof event.delta === 'string' - ? [event.delta] - : []; - } catch { - return []; - } - }) - .join(''); -} - -function assertHeadlessRetry(stdout: string): void { - const events = stdout - .split(/\r?\n/) - .filter(Boolean) - .flatMap((line) => { - try { - const event = JSON.parse(line) as Record; - return [event]; - } catch { - return []; - } - }); - const retryEvents = events.filter((event) => event.type === 'provider_retry'); - const circuitEvents = events.filter((event) => event.type === 'provider_circuit'); - const recoveryEvents = events.filter((event) => event.type === 'provider_recovery'); - expect( - retryEvents - .filter((event) => event.phase === 'attempt') - .map((event) => event.attempt) - .slice(0, 4) - ).toEqual([1, 2, 3, 4]); - expect(retryEvents).toContainEqual( - expect.objectContaining({ - phase: 'recovered', - attempt: 4, - max_retries: 12, - mode: 'bounded_foreground', - recovery_budget_ms: 120_000, - }) - ); - expect(circuitEvents.map((event) => event.phase)).toEqual([ - 'opened', - 'waiting', - 'probe', - 'closed', - ]); - expect(circuitEvents[1]).toMatchObject({ - phase: 'waiting', - reason: 'server_error', - status_code: 503, - retry_after_ms: CIRCUIT_OPEN_MS, - open_duration_ms: CIRCUIT_OPEN_MS, - }); - expect(recoveryEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - snapshot: expect.objectContaining({ activity: 'retry_attempt' }), - }), - expect.objectContaining({ - snapshot: expect.objectContaining({ activity: 'circuit_open' }), - }), - expect.objectContaining({ - snapshot: expect.objectContaining({ activity: 'circuit_probe' }), - }), - expect.objectContaining({ snapshot: null }), - ]) - ); - const recoveryGenerations = new Set( - recoveryEvents - .map((event) => event.generation) - .filter((generation): generation is string => typeof generation === 'string') - ); - expect(recoveryGenerations).toHaveLength(1); - const recoveryRevisions = recoveryEvents - .map((event) => event.revision) - .filter((revision): revision is number => typeof revision === 'number'); - expect(recoveryRevisions).toEqual( - [...recoveryRevisions].sort((left, right) => left - right) - ); -} - -async function runHeadless(input: { - workspace: string; - home: string; - storageRoot: string; - sessionId: string; - prompt: string; - marker: string; - secret: string; -}): Promise { - const child = spawn( - process.execPath, - [ - cliEntry, - '--headless', - '--output-format', - 'jsonl', - '--trust-workspace', - '--permission-mode', - 'yolo', - '--max-turns', - '8', - '--session-id', - input.sessionId, - '--allowed-tools', - 'Read,Edit,Bash', - '--no-verification-agent', - input.prompt, - ], - { - cwd: input.workspace, - env: childEnvironment(input.home, input.storageRoot), - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - let stdout = ''; - let stderr = ''; - child.stdout?.on('data', (chunk) => { - stdout = appendTail(stdout, chunk); - }); - child.stderr?.on('data', (chunk) => { - stderr = appendTail(stderr, chunk); - }); - try { - const exit = await waitForChildExit(child, 180_000); - const diagnostic = `${stdout}\n${stderr}`.replaceAll(input.secret, '[redacted]'); - if (exit.signal || exit.code !== 0) { - throw new Error( - `Headless Provider recovery exited ${exit.code ?? exit.signal}: ${diagnostic}` - ); - } - assertHeadlessRetry(stdout); - expect(parseHeadlessContent(stdout)).toContain(input.marker); - return { sessionId: input.sessionId, output: `${stdout}\n${stderr}` }; - } finally { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); - } -} - -async function runSubprocessRunner(input: { - runner: string; - envName: - | 'BLADE_FOREGROUND_PROVIDER_RECOVERY_ACP_INPUT' - | 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT'; - payload: Record; - timeoutMs: number; -}): Promise { - const encoded = Buffer.from(JSON.stringify(input.payload), 'utf8').toString('base64'); - let stdout = ''; - let stderr = ''; - try { - const result = await execFileAsync('bun', [input.runner], { - cwd: path.resolve(import.meta.dirname, '../../..'), - env: { ...process.env, [input.envName]: encoded }, - timeout: input.timeoutMs, - maxBuffer: 1024 * 1024, - killSignal: 'SIGKILL', - }); - stdout = result.stdout; - stderr = result.stderr; - } catch (error) { - const failed = error as Error & { stdout?: string; stderr?: string }; - stdout = failed.stdout ?? stdout; - stderr = failed.stderr ?? stderr; - } - let parsed: SurfaceEvidence & { success?: unknown; error?: unknown }; - try { - parsed = JSON.parse(stdout.trim()) as typeof parsed; - } catch (error) { - throw new Error( - `Provider recovery runner emitted invalid JSON: ${stderr.slice(-8_000)}`, - { cause: error } - ); - } - if ( - parsed.success !== true || - (input.envName === 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT' && - !isCompleteRawPtyMarkerEvidence(parsed)) - ) { - throw new Error(`Provider recovery runner failed: ${String(parsed.error)}`); - } - return parsed; -} - -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Provider recovery Web port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - -async function waitForHttp(url: string): Promise { - await waitFor( - async () => { - try { - return (await fetch(url)).ok; - } catch { - return false; - } - }, - `Timed out waiting for ${url}`, - 20_000 - ); -} - -async function createWebSession(origin: string, projectPath: string): Promise { - const response = await fetch(`${origin}/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - projectPath, - title: 'Bounded foreground Provider recovery', - }), - }); - if (!response.ok) { - throw new Error(`Web Provider recovery Session failed: ${response.status}`); - } - const created = (await response.json()) as { sessionId?: unknown }; - if (typeof created.sessionId !== 'string') { - throw new Error('Web Provider recovery Session returned no ID'); - } - return created.sessionId; -} - -async function openSessionEventProbe( - origin: string, - sessionId: string, - projectPath: string -): Promise { - const controller = new AbortController(); - const url = new URL(`${origin}/sessions/${sessionId}/events`); - url.searchParams.set('projectPath', projectPath); - const response = await fetch(url, { signal: controller.signal }); - if (!response.ok || !response.body) { - controller.abort(); - throw new Error(`Web Provider recovery SSE failed: ${response.status}`); - } - const events: SessionEventProbe['events'] = []; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let readingError: unknown; - const consume = (frame: string) => { - const data = frame - .split(/\r?\n/) - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).trimStart()) - .join('\n'); - if (!data) return; - const event = JSON.parse(data) as { type?: unknown; properties?: unknown }; - if ( - typeof event.type === 'string' && - event.properties && - typeof event.properties === 'object' && - !Array.isArray(event.properties) - ) { - events.push({ - type: event.type, - properties: event.properties as Record, - }); - } - }; - const reading = (async () => { - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - buffer += decoder.decode(chunk.value, { stream: true }); - const frames = buffer.split(/\r?\n\r?\n/); - buffer = frames.pop() ?? ''; - for (const frame of frames) consume(frame); - } - } catch (error) { - if (!controller.signal.aborted) readingError = error; - } - })(); - await waitFor( - () => events.some((event) => event.type === 'connected'), - 'Web Provider recovery SSE did not connect', - 20_000 - ); - return { - events, - async close() { - controller.abort(); - await reader.cancel().catch(() => undefined); - await reading; - if (readingError) { - throw new Error('Web Provider recovery SSE read failed', { - cause: readingError, - }); - } - }, - }; -} - -async function openWebSessionPage( - browser: Browser, - origin: string, - sessionId: string, - projectPath: string -): Promise { - const page = await browser.newPage(); - const navigation = new URL(origin); - navigation.searchParams.set('session', sessionId); - navigation.searchParams.set('project', projectPath); - await page.goto(navigation.href, { waitUntil: 'domcontentloaded' }); - await page.locator('textarea[data-blade-composer]').waitFor({ state: 'visible' }); - const permissionMode = page.locator('[data-blade-permission-mode]'); - await permissionMode.waitFor({ state: 'visible' }); - if ((await permissionMode.getAttribute('data-blade-permission-mode')) !== 'yolo') { - await permissionMode.click(); - await page.locator('[data-blade-permission-option="yolo"]').click(); - await page.locator('[data-blade-yolo-confirm]').click(); - await page.waitForFunction( - () => - document - .querySelector('[data-blade-permission-mode]') - ?.getAttribute('data-blade-permission-mode') === 'yolo' - ); - } - return page; -} - -async function runWeb(input: { - workspace: string; - home: string; - storageRoot: string; - prompt: string; - marker: string; - secondaryPrompt: string; - secondaryMarker: string; - secret: string; -}): Promise { - const port = await reservePort(); - const child = spawn( - process.execPath, - [cliEntry, 'serve', '--hostname', '127.0.0.1', '--port', String(port)], - { - cwd: input.workspace, - env: childEnvironment(input.home, input.storageRoot), - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - let output = ''; - child.stdout?.on('data', (chunk) => { - output = appendTail(output, chunk); - }); - child.stderr?.on('data', (chunk) => { - output = appendTail(output, chunk); - }); - let browser: Browser | undefined; - let probe: SessionEventProbe | undefined; - let reconnectProbe: SessionEventProbe | undefined; - let secondaryProbe: SessionEventProbe | undefined; - try { - const origin = `http://127.0.0.1:${port}`; - await waitForHttp(`${origin}/health`); - const sessionId = await createWebSession(origin, input.workspace); - probe = await openSessionEventProbe(origin, sessionId, input.workspace); - browser = await chromium.launch({ headless: true }); - const page = await openWebSessionPage(browser, origin, sessionId, input.workspace); - const consoleErrors: string[] = []; - page.on('console', (message) => { - if (message.type() === 'error') consoleErrors.push(message.text()); - }); - const composer = page.locator('textarea[data-blade-composer]'); - await composer.fill(input.prompt); - const submission = page.waitForResponse( - (response) => - response.request().method() === 'POST' && - response.url().includes(`/sessions/${sessionId}/message`) - ); - await composer.press('Enter'); - if (!(await submission).ok()) { - throw new Error('Web Provider recovery prompt submission failed'); - } - - await waitFor( - () => - probe?.events.some( - (event) => - event.type === 'provider.retry' && - event.properties.phase === 'scheduled' && - event.properties.attempt === 4 && - event.properties.maxRetries === 12 && - event.properties.mode === 'bounded_foreground' - ) === true, - 'Web SSE did not project the fourth bounded Provider retry', - 60_000 - ); - await waitFor( - () => - probe?.events.some( - (event) => - event.type === 'provider.circuit' && - event.properties.phase === 'waiting' && - event.properties.retryAfterMs === CIRCUIT_OPEN_MS - ) === true, - 'Web SSE did not project shared Provider circuit waiting', - 30_000 - ); - const recoveryBanner = page.locator('[data-provider-recovery-banner]'); - await recoveryBanner.waitFor({ - state: 'visible', - timeout: 10_000, - }); - expect(await recoveryBanner.textContent()).toContain('Provider isolated'); - reconnectProbe = await openSessionEventProbe(origin, sessionId, input.workspace); - expect(reconnectProbe.events[0]).toMatchObject({ - type: 'connected', - properties: { - providerRecovery: expect.objectContaining({ - snapshot: expect.objectContaining({ activity: 'circuit_open' }), - }), - }, - }); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.locator('textarea[data-blade-composer]').waitFor({ state: 'visible' }); - const reconnectedRecoveryBanner = page.locator('[data-provider-recovery-banner]'); - await reconnectedRecoveryBanner.waitFor({ - state: 'visible', - timeout: 10_000, - }); - expect(await reconnectedRecoveryBanner.textContent()).toContain( - 'Provider isolated' - ); - await page.locator('[data-provider-recovery-stop]').waitFor({ - state: 'visible', - timeout: 10_000, - }); - const secondarySessionId = await createWebSession(origin, input.workspace); - secondaryProbe = await openSessionEventProbe( - origin, - secondarySessionId, - input.workspace - ); - const secondarySubmittedAt = Date.now(); - const secondarySubmission = await fetch( - `${origin}/sessions/${secondarySessionId}/message`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - content: input.secondaryPrompt, - permissionMode: 'yolo', - }), - } - ); - if (!secondarySubmission.ok) { - throw new Error( - `Secondary Web Provider circuit prompt failed: ${secondarySubmission.status}` - ); - } - await waitFor( - () => - secondaryProbe?.events.some( - (event) => - event.type === 'provider.circuit' && event.properties.phase === 'waiting' - ) === true, - 'Secondary Web Session did not wait on the shared Provider circuit', - 30_000 - ); - await waitFor( - () => - probe?.events.some( - (event) => - event.type === 'provider.retry' && - event.properties.phase === 'recovered' && - event.properties.attempt === 4 - ) === true, - 'Web SSE did not project Provider recovery', - 120_000 - ); - await waitFor( - () => { - const phases = [...(probe?.events ?? []), ...(secondaryProbe?.events ?? [])] - .filter((event) => event.type === 'provider.circuit') - .map((event) => event.properties.phase); - return phases.includes('probe') && phases.includes('closed'); - }, - 'Web SSE did not project Provider circuit probe and close', - 120_000 - ); - await page.getByText(input.marker, { exact: true }).waitFor({ - state: 'visible', - timeout: 180_000, - }); - let secondaryTranscript = ''; - let secondaryTranscriptPath = ''; - await waitFor( - async () => { - try { - secondaryTranscriptPath = findSessionTranscript( - input.storageRoot, - secondarySessionId - ); - secondaryTranscript = await readFile(secondaryTranscriptPath, 'utf8'); - return ( - finalAssistantText(readSessionEvents(secondaryTranscriptPath)) === - input.secondaryMarker - ); - } catch { - return false; - } - }, - 'Secondary Web Session did not complete after circuit close', - 180_000 - ); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.locator('textarea[data-blade-composer]').waitFor({ state: 'visible' }); - await page.getByText(input.marker, { exact: true }).waitFor({ - state: 'visible', - timeout: 30_000, - }); - expect(consoleErrors).toEqual([]); - const html = await page.content(); - const eventOutput = JSON.stringify({ - primary: probe.events, - secondary: secondaryProbe.events, - reconnect: reconnectProbe.events, - }); - const providerEvents = [...probe.events, ...secondaryProbe.events].filter( - (event) => event.type === 'provider.retry' || event.type === 'provider.circuit' - ); - const recoveryEvents = [ - ...probe.events, - ...secondaryProbe.events, - ...reconnectProbe.events, - ].filter((event) => event.type === 'provider.recovery'); - const providerProbeCount = providerEvents.filter( - (event) => event.type === 'provider.circuit' && event.properties.phase === 'probe' - ).length; - assertNoSecrets( - { - output, - html, - primaryEvents: probe.events, - secondaryEvents: secondaryProbe.events, - reconnectEvents: reconnectProbe.events, - secondaryTranscript, - }, - [input.secret] - ); - - await probe.close(); - probe = undefined; - await secondaryProbe.close(); - secondaryProbe = undefined; - await reconnectProbe.close(); - reconnectProbe = undefined; - await browser.close(); - browser = undefined; - child.kill('SIGTERM'); - const exit = await waitForChildExit(child); - if (exit.signal || exit.code !== 0) { - throw new Error( - `Web Provider recovery exited ${exit.code ?? exit.signal}: ${output.replaceAll( - input.secret, - '[redacted]' - )}` - ); - } - return { - sessionId, - secondarySessionId, - secondarySubmittedAt, - providerProbeCount, - sawBoundedForegroundRecovery: providerEvents.some( - (event) => event.properties.mode === 'bounded_foreground' - ), - sawProviderLifecycle: providerEvents.length > 0, - sawProviderRecovery: - recoveryEvents.some( - (event) => - event.properties.recovery && - /retry_attempt|retry_wait|circuit_open|circuit_probe/.test( - JSON.stringify(event.properties.recovery) - ) - ) && - recoveryEvents.some( - (event) => - event.properties.recovery && - JSON.stringify(event.properties.recovery).includes('"snapshot":null') - ), - output: `${output}\n${html}`.slice(-MAX_CAPTURE_CHARS), - protocolOutput: eventOutput.slice(-MAX_CAPTURE_CHARS), - }; - } finally { - await probe?.close().catch(() => undefined); - await reconnectProbe?.close().catch(() => undefined); - await secondaryProbe?.close().catch(() => undefined); - await browser?.close().catch(() => undefined); - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); - await waitForChildExit(child, 10_000).catch(() => undefined); - } - } -} - -function toolCallNames(events: ReturnType): string[] { - return events.flatMap((event) => { - if (event.type !== 'part_created' || event.data.partType !== 'tool_call') { - return []; - } - const payload = event.data.payload; - return payload && - typeof payload === 'object' && - !Array.isArray(payload) && - typeof payload.toolName === 'string' - ? [payload.toolName] - : []; - }); -} - -describe - .skipIf(!isRealApiTestEnabled()) - .sequential('bounded foreground Provider recovery release matrix', () => { - it.each(matrix)( - '$model.model × $surface', - async ({ model, surface }) => { - const root = await mkdtemp( - path.join( - os.tmpdir(), - `blade-provider-recovery-${safeSlug(model.model)}-${surface}-` - ) - ); - const home = path.join(root, 'home'); - const storage = path.join(root, 'storage'); - const workspaceInput = path.join(root, 'workspace'); - const proxy = await startRecoveryProxy( - model.baseURL ?? 'https://api.deepseek.com' - ); - let sessionId = `provider-recovery-${safeSlug( - model.model - )}-${surface}-${Date.now()}`; - const marker = `PROVIDER_RECOVERY_OK_${Date.now()}`; - const secondaryMarker = `PROVIDER_CIRCUIT_SHARED_OK_${Date.now()}`; - const prompt = [ - '[PASTE: bounded foreground provider recovery]', - 'Read src/add.js and test/add.test.js. Use Edit exactly once to replace ' + - '"return left - right;" with "return left + right;" in src/add.js.', - 'Do not use Write or any other mutation tool. Then call Bash exactly once ' + - 'with command "npm test".', - createSplitPtyMarkerInstruction(marker), - ].join('\n'); - if (prompt.includes(marker)) { - throw new Error('Provider recovery final marker contaminated the prompt'); - } - const secondaryPrompt = createSplitPtyMarkerInstruction(secondaryMarker); - if (secondaryPrompt.includes(secondaryMarker)) { - throw new Error('Secondary Provider recovery marker contaminated the prompt'); - } - try { - await Promise.all([ - mkdir(home, { recursive: true }), - mkdir(storage, { recursive: true }), - mkdir(workspaceInput, { recursive: true }), - ]); - const workspace = await initializeWorkspace(workspaceInput); - await writeRuntimeConfig(home, model, proxy.baseURL); - - let evidence: SurfaceEvidence; - if (surface === 'headless') { - evidence = await runHeadless({ - workspace, - home, - storageRoot: storage, - sessionId, - prompt, - marker, - secret: model.apiKey, - }); - } else if (surface === 'acp') { - evidence = await runSubprocessRunner({ - runner: acpRunner, - envName: 'BLADE_FOREGROUND_PROVIDER_RECOVERY_ACP_INPUT', - payload: { - cliEntry, - workspace, - home, - storageRoot: storage, - prompt, - marker, - secondaryPrompt, - secondaryMarker, - secret: model.apiKey, - }, - timeoutMs: 240_000, - }); - sessionId = evidence.sessionId; - } else if (surface === 'pty') { - evidence = await runSubprocessRunner({ - runner: ptyRunner, - envName: 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT', - payload: { - cliEntry, - workspace, - home, - storageRoot: storage, - sessionId, - prompt, - marker, - secret: model.apiKey, - }, - timeoutMs: 480_000, - }); - } else { - evidence = await runWeb({ - workspace, - home, - storageRoot: storage, - prompt, - marker, - secondaryPrompt, - secondaryMarker, - secret: model.apiKey, - }); - sessionId = evidence.sessionId; - } - - expect(proxy.injectedFailures()).toBe(INJECTED_FAILURES); - expect(proxy.requestCount()).toBeGreaterThanOrEqual(INJECTED_FAILURES + 1); - expect(proxy.forwardedRequests()).toBeGreaterThanOrEqual(1); - const circuitOpenedAt = proxy.circuitOpenedAt(); - const firstPostOpenRequestAt = proxy.requestStartedAt()[INJECTED_FAILURES]; - expect(circuitOpenedAt).toBeTypeOf('number'); - expect(firstPostOpenRequestAt).toBeTypeOf('number'); - expect( - (firstPostOpenRequestAt ?? 0) - (circuitOpenedAt ?? 0) - ).toBeGreaterThanOrEqual(CIRCUIT_OPEN_MS - 50); - if (surface === 'acp' || surface === 'web') { - expect(proxy.forwardedRequests()).toBeGreaterThanOrEqual(2); - expect(evidence.secondarySessionId).toBeTypeOf('string'); - expect(evidence.secondarySubmittedAt).toBeTypeOf('number'); - expect( - proxy - .requestStartedAt() - .filter( - (startedAt) => - startedAt >= (evidence.secondarySubmittedAt ?? 0) && - startedAt < (circuitOpenedAt ?? 0) + CIRCUIT_OPEN_MS - 50 - ) - ).toHaveLength(0); - expect(evidence.providerProbeCount).toBe(1); - const secondaryTranscriptPath = findSessionTranscript( - storage, - evidence.secondarySessionId as string - ); - const secondaryTranscript = await readFile(secondaryTranscriptPath, 'utf8'); - const secondaryEvents = readSessionEvents(secondaryTranscriptPath); - expect(finalAssistantText(secondaryEvents)).toBe(secondaryMarker); - assertNoSecrets({ secondaryTranscript }, [ - model.apiKey, - proxy.privateBodyMarker, - ]); - } - if (surface === 'pty') { - expect(evidence.sawProviderRecoveryWait).toBe(true); - expect(evidence.sawProviderRecoveryProbe).toBe(true); - } else { - const protocolOutput = evidence.protocolOutput ?? evidence.output; - expect( - evidence.sawBoundedForegroundRecovery ?? - protocolOutput.includes('bounded_foreground') - ).toBe(true); - expect( - evidence.sawProviderLifecycle ?? - /provider[_./]circuit|blade\/providerCircuit/.test(protocolOutput) - ).toBe(true); - expect( - evidence.sawProviderRecovery ?? - /provider[_./]recovery|blade\/providerRecovery/.test(protocolOutput) - ).toBe(true); - } - if (surface === 'acp') { - expect(evidence.terminalReleaseCount).toBeGreaterThanOrEqual(1); - } - - const transcriptPath = findSessionTranscript(storage, sessionId); - const transcript = await readFile(transcriptPath, 'utf8'); - const events = readSessionEvents(transcriptPath); - const toolNames = toolCallNames(events); - expect( - toolNames.filter((name) => ['Edit', 'Write', 'ApplyPatch'].includes(name)) - ).toEqual(['Edit']); - expect(toolNames.filter((name) => name === 'Bash')).toHaveLength(1); - expect(finalAssistantText(events)).toBe(marker); - expect( - await readFile(path.join(workspace, 'src', 'add.js'), 'utf8') - ).toContain('return left + right;'); - const verification = await execFileAsync(process.execPath, ['--test'], { - cwd: workspace, - timeout: 30_000, - }); - expect(verification.stdout).toContain('pass 1'); - const diff = await execFileAsync('git', ['diff', '--name-only'], { - cwd: workspace, - }); - expect(diff.stdout.trim()).toBe('src/add.js'); - assertNoSecrets( - { - evidence, - transcript, - }, - [model.apiKey, proxy.privateBodyMarker] - ); - for (const process of evidence.processes ?? []) { - expect(processIdentityMatches(process.pid, process.identity)).toBe(false); - } - } finally { - await proxy.close(); - await removeTestDirectory(root); - } - }, - 540_000 - ); - }); +defineProviderRecoveryTrajectory('recovery'); diff --git a/packages/cli/tests/integration/real-api/provider-rate-limit-cooldown-trajectory.test.ts b/packages/cli/tests/integration/real-api/provider-rate-limit-cooldown-trajectory.test.ts index 2b93aad10..7d9742d82 100644 --- a/packages/cli/tests/integration/real-api/provider-rate-limit-cooldown-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/provider-rate-limit-cooldown-trajectory.test.ts @@ -1,1259 +1,3 @@ -import { type ChildProcess, execFile, spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, realpath, writeFile } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import type { AddressInfo } from 'node:net'; -import { createServer as createNetServer } from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { type Browser, chromium, type Page } from 'playwright'; -import { describe, expect, it, type TestContext } from 'vitest'; -import { - type ProcessIdentity, - processIdentityMatches, -} from '../../../src/utils/process/ProcessIdentity.js'; -import { - createSplitPtyMarkerInstruction, - isCompleteRawPtyMarkerEvidence, -} from '../../support/foregroundBoundedOutputPtyDriver.js'; -import { removeTestDirectory } from '../../support/helpers/removeTestDirectory.js'; -import { - assertNoSecrets, - finalAssistantText, - findSessionTranscript, - readSessionEvents, -} from './sessionForkTrajectoryHarness.js'; -import { - buildRealApiRuntimeConfig, - isRealApiTestEnabled, - isReleaseMatrix, - resolveRequiredDeepSeekQualificationModels, - type TestModelConfig, -} from './testConfig.js'; +import { defineProviderRecoveryTrajectory } from './providerRecoveryTrajectoryHarness.js'; -const execFileAsync = promisify(execFile); -const surfaces = ['headless', 'acp', 'pty', 'web'] as const; -const models = isRealApiTestEnabled() - ? resolveRequiredDeepSeekQualificationModels() - : []; -const matrix = models.flatMap((model) => - surfaces.map((surface) => ({ model, surface })) -); -if (isRealApiTestEnabled() && matrix.length !== 8) { - throw new Error( - `Provider rate-limit cooldown matrix must contain 8 cells, got ${matrix.length}` - ); -} -if (isRealApiTestEnabled() && !isReleaseMatrix()) { - throw new Error( - 'Provider rate-limit cooldown qualification requires REAL_API_RELEASE_MATRIX=1' - ); -} - -const cliEntry = path.resolve(import.meta.dirname, '../../../dist/blade.js'); -const acpRunner = path.resolve( - import.meta.dirname, - '../../support/foregroundProviderRecoveryAcpRunner.ts' -); -const ptyRunner = path.resolve( - import.meta.dirname, - '../../support/foregroundProviderRecoveryPtyRunner.ts' -); -const MAX_CAPTURE_CHARS = 256_000; -const INJECTED_FAILURES = 1; -const CIRCUIT_OPEN_MS = 2_000; - -interface SurfaceEvidence { - sessionId: string; - output: string; - protocolOutput?: string; - finalMarkerSeen?: boolean; - secretSeen?: boolean; - processes?: Array<{ pid: number; identity: ProcessIdentity }>; - terminalReleaseCount?: number; - secondarySessionId?: string; - secondarySubmittedAt?: number; - providerProbeCount?: number; - sawBoundedForegroundRecovery?: boolean; - sawProviderLifecycle?: boolean; - sawProviderRecovery?: boolean; - sawProviderRecoveryWait?: boolean; - sawProviderRecoveryProbe?: boolean; - sawTurnActivity?: boolean; - sawToolActivity?: boolean; - terminalClearSeen?: boolean; -} - -interface SessionEventProbe { - events: Array<{ type: string; properties: Record }>; - close(): Promise; -} - -function safeSlug(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -function frameworkRetryBudget(context: TestContext): number { - const retry = context.task.retry; - return typeof retry === 'number' ? retry : (retry?.count ?? 0); -} - -function appendTail(current: string, chunk: Buffer | string): string { - return `${current}${chunk.toString()}`.slice(-MAX_CAPTURE_CHARS); -} - -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 120_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Provider recovery surface process did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -function upstreamUrl(baseUrl: string, requestUrl: string | undefined): URL { - const target = new URL(baseUrl); - const incoming = new URL(requestUrl ?? '/', 'http://127.0.0.1'); - target.pathname = `${target.pathname.replace(/\/$/, '')}/${incoming.pathname.replace( - /^\//, - '' - )}`; - target.search = incoming.search; - return target; -} - -async function readRequestBody( - request: import('node:http').IncomingMessage -): Promise { - const chunks: Buffer[] = []; - for await (const chunk of request) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks); -} - -async function startRecoveryProxy(baseUrl: string) { - let requestCount = 0; - let injectedFailures = 0; - let forwardedRequests = 0; - let circuitOpenedAt: number | undefined; - const requestStartedAt: number[] = []; - const privateBodyMarker = 'PRIVATE_RATE_LIMIT_COOLDOWN_BODY_MUST_NOT_SURFACE'; - const server = createServer((request, response) => { - void (async () => { - const body = await readRequestBody(request); - requestCount++; - requestStartedAt.push(Date.now()); - if (injectedFailures < INJECTED_FAILURES) { - injectedFailures++; - response.writeHead(429, { - 'content-type': 'application/json', - 'retry-after-ms': String(CIRCUIT_OPEN_MS), - }); - response.end( - JSON.stringify({ - error: { - type: 'rate_limit_error', - message: privateBodyMarker, - }, - }) - ); - circuitOpenedAt = Date.now(); - return; - } - - forwardedRequests++; - const headers = new Headers(); - for (const [name, value] of Object.entries(request.headers)) { - if (!value || name === 'host' || name === 'content-length') continue; - headers.set(name, Array.isArray(value) ? value.join(', ') : value); - } - const upstream = await fetch(upstreamUrl(baseUrl, request.url), { - method: request.method ?? 'POST', - headers, - body: - request.method === 'GET' || request.method === 'HEAD' - ? undefined - : body.toString('utf8'), - redirect: 'manual', - }); - const responseHeaders: Record = {}; - upstream.headers.forEach((value, name) => { - if ( - ![ - 'connection', - 'content-encoding', - 'content-length', - 'transfer-encoding', - ].includes(name) - ) { - responseHeaders[name] = value; - } - }); - response.writeHead(upstream.status, responseHeaders); - if (upstream.body) { - const reader = upstream.body.getReader(); - for (;;) { - const chunk = await reader.read(); - if (chunk.done) break; - response.write(Buffer.from(chunk.value)); - } - } - response.end(); - })().catch((error: unknown) => { - if (response.headersSent) { - response.destroy(error instanceof Error ? error : undefined); - return; - } - response.writeHead(502, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ error: { type: 'proxy_error' } })); - }); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - server.off('error', reject); - resolve(); - }); - }); - const address = server.address() as AddressInfo; - return { - baseURL: `http://127.0.0.1:${address.port}`, - privateBodyMarker, - requestCount: () => requestCount, - injectedFailures: () => injectedFailures, - forwardedRequests: () => forwardedRequests, - circuitOpenedAt: () => circuitOpenedAt, - requestStartedAt: () => [...requestStartedAt], - close: async () => { - server.closeAllConnections(); - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - }, - }; -} - -async function initializeWorkspace(workspace: string): Promise { - await Promise.all([ - mkdir(path.join(workspace, 'src'), { recursive: true }), - mkdir(path.join(workspace, 'test'), { recursive: true }), - ]); - await writeFile( - path.join(workspace, 'package.json'), - `${JSON.stringify( - { - name: 'blade-rate-limit-cooldown', - private: true, - type: 'module', - scripts: { test: 'node --test' }, - }, - null, - 2 - )}\n` - ); - await writeFile( - path.join(workspace, 'src', 'add.js'), - 'export function add(left, right) {\n return left - right;\n}\n' - ); - await writeFile( - path.join(workspace, 'test', 'add.test.js'), - [ - "import assert from 'node:assert/strict';", - "import test from 'node:test';", - "import { add } from '../src/add.js';", - '', - "test('adds two numbers', () => {", - ' assert.equal(add(4, 3), 7);', - '});', - '', - ].join('\n') - ); - await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); - await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, - }); - await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, - }); - await execFileAsync('git', ['add', '.'], { cwd: workspace }); - await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); - return realpath(workspace); -} - -async function writeRuntimeConfig( - home: string, - model: TestModelConfig, - baseURL: string -): Promise { - const config = buildRealApiRuntimeConfig({ ...model, baseURL }); - const modelsWithoutRetryOverride = config.models.map((entry) => { - const overrides = { ...entry.overrides }; - delete overrides.maxRetries; - return { ...entry, overrides }; - }); - const bladeHome = path.join(home, '.blade'); - const skillCreator = path.join(bladeHome, 'skills', 'skill-creator'); - await Promise.all([ - mkdir(bladeHome, { recursive: true }), - mkdir(skillCreator, { recursive: true }), - ]); - await Promise.all([ - writeFile( - path.join(bladeHome, 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: modelsWithoutRetryOverride, - modelProviders: config.modelProviders, - permissionMode: 'yolo', - providerForegroundRecoveryMs: 120_000, - providerCircuitBreakerOpenMs: CIRCUIT_OPEN_MS, - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - null, - 2 - )}\n`, - { mode: 0o600 } - ), - writeFile( - path.join(skillCreator, 'SKILL.md'), - [ - '---', - 'name: skill-creator', - 'description: Local deterministic qualification fixture.', - '---', - '', - '# Fixture Skill Creator', - '', - ].join('\n') - ), - ]); -} - -function childEnvironment(home: string, storageRoot: string): NodeJS.ProcessEnv { - return { - ...process.env, - HOME: home, - BLADE_STORAGE_ROOT: storageRoot, - BLADE_AUTO_MEMORY: '0', - BLADE_TELEMETRY_DISABLED: '1', - TERM: 'xterm-256color', - }; -} - -function parseHeadlessContent(stdout: string): string { - return stdout - .split(/\r?\n/) - .filter(Boolean) - .flatMap((line) => { - try { - const event = JSON.parse(line) as Record; - return event.type === 'content_delta' && typeof event.delta === 'string' - ? [event.delta] - : []; - } catch { - return []; - } - }) - .join(''); -} - -function assertHeadlessRateLimitCooldown(stdout: string): void { - const events = stdout - .split(/\r?\n/) - .filter(Boolean) - .flatMap((line) => { - try { - const event = JSON.parse(line) as Record; - return [event]; - } catch { - return []; - } - }); - const retryEvents = events.filter((event) => event.type === 'provider_retry'); - const circuitEvents = events.filter((event) => event.type === 'provider_circuit'); - const recoveryEvents = events.filter((event) => event.type === 'provider_recovery'); - const activityEvents = events.filter((event) => event.type === 'turn_activity'); - expect( - retryEvents - .filter((event) => event.phase === 'attempt') - .map((event) => event.attempt) - ).toEqual([1]); - expect(retryEvents).toContainEqual( - expect.objectContaining({ - phase: 'recovered', - attempt: 1, - max_retries: 12, - mode: 'bounded_foreground', - recovery_budget_ms: 120_000, - }) - ); - expect(circuitEvents.map((event) => event.phase)).toEqual([ - 'opened', - 'waiting', - 'probe', - 'closed', - ]); - expect(circuitEvents[1]).toMatchObject({ - phase: 'waiting', - reason: 'rate_limit', - status_code: 429, - retry_after_ms: CIRCUIT_OPEN_MS, - open_duration_ms: CIRCUIT_OPEN_MS, - }); - expect(recoveryEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - snapshot: expect.objectContaining({ activity: 'circuit_open' }), - }), - expect.objectContaining({ - snapshot: expect.objectContaining({ activity: 'circuit_probe' }), - }), - expect.objectContaining({ snapshot: null }), - ]) - ); - const recoveryGenerations = new Set( - recoveryEvents - .map((event) => event.generation) - .filter((generation): generation is string => typeof generation === 'string') - ); - expect(recoveryGenerations).toHaveLength(1); - const recoveryRevisions = recoveryEvents - .map((event) => event.revision) - .filter((revision): revision is number => typeof revision === 'number'); - expect(recoveryRevisions).toEqual( - [...recoveryRevisions].sort((left, right) => left - right) - ); - expect(activityEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - snapshot: expect.objectContaining({ - phase: 'executing_tools', - active_tools: expect.arrayContaining([ - expect.objectContaining({ name: 'Bash' }), - ]), - }), - }), - expect.objectContaining({ snapshot: null }), - ]) - ); -} - -async function runHeadless(input: { - workspace: string; - home: string; - storageRoot: string; - sessionId: string; - prompt: string; - marker: string; - secret: string; -}): Promise { - const child = spawn( - process.execPath, - [ - cliEntry, - '--headless', - '--output-format', - 'jsonl', - '--trust-workspace', - '--permission-mode', - 'yolo', - '--max-turns', - '8', - '--session-id', - input.sessionId, - '--allowed-tools', - 'Read,Edit,Bash', - '--no-verification-agent', - input.prompt, - ], - { - cwd: input.workspace, - env: childEnvironment(input.home, input.storageRoot), - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - let stdout = ''; - let stderr = ''; - child.stdout?.on('data', (chunk) => { - stdout = appendTail(stdout, chunk); - }); - child.stderr?.on('data', (chunk) => { - stderr = appendTail(stderr, chunk); - }); - try { - const exit = await waitForChildExit(child, 180_000); - const diagnostic = `${stdout}\n${stderr}`.replaceAll(input.secret, '[redacted]'); - if (exit.signal || exit.code !== 0) { - throw new Error( - `Headless Provider recovery exited ${exit.code ?? exit.signal}: ${diagnostic}` - ); - } - assertHeadlessRateLimitCooldown(stdout); - expect(parseHeadlessContent(stdout)).toContain(input.marker); - return { sessionId: input.sessionId, output: `${stdout}\n${stderr}` }; - } finally { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); - } -} - -async function runSubprocessRunner(input: { - runner: string; - envName: - | 'BLADE_FOREGROUND_PROVIDER_RECOVERY_ACP_INPUT' - | 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT'; - payload: Record; - timeoutMs: number; -}): Promise { - const encoded = Buffer.from(JSON.stringify(input.payload), 'utf8').toString('base64'); - let stdout = ''; - let stderr = ''; - try { - const result = await execFileAsync('bun', [input.runner], { - cwd: path.resolve(import.meta.dirname, '../../..'), - env: { ...process.env, [input.envName]: encoded }, - timeout: input.timeoutMs, - maxBuffer: 1024 * 1024, - killSignal: 'SIGKILL', - }); - stdout = result.stdout; - stderr = result.stderr; - } catch (error) { - const failed = error as Error & { stdout?: string; stderr?: string }; - stdout = failed.stdout ?? stdout; - stderr = failed.stderr ?? stderr; - } - let parsed: SurfaceEvidence & { success?: unknown; error?: unknown }; - try { - parsed = JSON.parse(stdout.trim()) as typeof parsed; - } catch (error) { - throw new Error( - `Provider recovery runner emitted invalid JSON: ${stderr.slice(-8_000)}`, - { cause: error } - ); - } - if ( - parsed.success !== true || - (input.envName === 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT' && - !isCompleteRawPtyMarkerEvidence(parsed)) - ) { - throw new Error(`Provider recovery runner failed: ${String(parsed.error)}`); - } - return parsed; -} - -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Provider recovery Web port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - -async function waitForHttp(url: string): Promise { - await waitFor( - async () => { - try { - return (await fetch(url)).ok; - } catch { - return false; - } - }, - `Timed out waiting for ${url}`, - 20_000 - ); -} - -async function createWebSession(origin: string, projectPath: string): Promise { - const response = await fetch(`${origin}/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - projectPath, - title: 'Bounded foreground Provider recovery', - }), - }); - if (!response.ok) { - throw new Error(`Web Provider recovery Session failed: ${response.status}`); - } - const created = (await response.json()) as { sessionId?: unknown }; - if (typeof created.sessionId !== 'string') { - throw new Error('Web Provider recovery Session returned no ID'); - } - return created.sessionId; -} - -async function openSessionEventProbe( - origin: string, - sessionId: string, - projectPath: string -): Promise { - const controller = new AbortController(); - const url = new URL(`${origin}/sessions/${sessionId}/events`); - url.searchParams.set('projectPath', projectPath); - const response = await fetch(url, { signal: controller.signal }); - if (!response.ok || !response.body) { - controller.abort(); - throw new Error(`Web Provider recovery SSE failed: ${response.status}`); - } - const events: SessionEventProbe['events'] = []; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let readingError: unknown; - const consume = (frame: string) => { - const data = frame - .split(/\r?\n/) - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).trimStart()) - .join('\n'); - if (!data) return; - const event = JSON.parse(data) as { type?: unknown; properties?: unknown }; - if ( - typeof event.type === 'string' && - event.properties && - typeof event.properties === 'object' && - !Array.isArray(event.properties) - ) { - events.push({ - type: event.type, - properties: event.properties as Record, - }); - } - }; - const reading = (async () => { - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - buffer += decoder.decode(chunk.value, { stream: true }); - const frames = buffer.split(/\r?\n\r?\n/); - buffer = frames.pop() ?? ''; - for (const frame of frames) consume(frame); - } - } catch (error) { - if (!controller.signal.aborted) readingError = error; - } - })(); - await waitFor( - () => events.some((event) => event.type === 'connected'), - 'Web Provider recovery SSE did not connect', - 20_000 - ); - return { - events, - async close() { - controller.abort(); - await reader.cancel().catch(() => undefined); - await reading; - if (readingError) { - throw new Error('Web Provider recovery SSE read failed', { - cause: readingError, - }); - } - }, - }; -} - -async function openWebSessionPage( - browser: Browser, - origin: string, - sessionId: string, - projectPath: string -): Promise { - const page = await browser.newPage(); - const navigation = new URL(origin); - navigation.searchParams.set('session', sessionId); - navigation.searchParams.set('project', projectPath); - await page.goto(navigation.href, { waitUntil: 'domcontentloaded' }); - await page.locator('textarea[data-blade-composer]').waitFor({ state: 'visible' }); - const permissionMode = page.locator('[data-blade-permission-mode]'); - await permissionMode.waitFor({ state: 'visible' }); - if ((await permissionMode.getAttribute('data-blade-permission-mode')) !== 'yolo') { - await permissionMode.click(); - await page.locator('[data-blade-permission-option="yolo"]').click(); - await page.locator('[data-blade-yolo-confirm]').click(); - await page.waitForFunction( - () => - document - .querySelector('[data-blade-permission-mode]') - ?.getAttribute('data-blade-permission-mode') === 'yolo' - ); - } - return page; -} - -async function runWeb(input: { - workspace: string; - home: string; - storageRoot: string; - prompt: string; - marker: string; - secondaryPrompt: string; - secondaryMarker: string; - secret: string; -}): Promise { - const port = await reservePort(); - const child = spawn( - process.execPath, - [cliEntry, 'serve', '--hostname', '127.0.0.1', '--port', String(port)], - { - cwd: input.workspace, - env: childEnvironment(input.home, input.storageRoot), - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - let output = ''; - child.stdout?.on('data', (chunk) => { - output = appendTail(output, chunk); - }); - child.stderr?.on('data', (chunk) => { - output = appendTail(output, chunk); - }); - let browser: Browser | undefined; - let probe: SessionEventProbe | undefined; - let reconnectProbe: SessionEventProbe | undefined; - let secondaryProbe: SessionEventProbe | undefined; - try { - const origin = `http://127.0.0.1:${port}`; - await waitForHttp(`${origin}/health`); - const sessionId = await createWebSession(origin, input.workspace); - probe = await openSessionEventProbe(origin, sessionId, input.workspace); - browser = await chromium.launch({ headless: true }); - const page = await openWebSessionPage(browser, origin, sessionId, input.workspace); - const consoleErrors: string[] = []; - page.on('console', (message) => { - if (message.type() === 'error') consoleErrors.push(message.text()); - }); - const composer = page.locator('textarea[data-blade-composer]'); - await composer.fill(input.prompt); - const submission = page.waitForResponse( - (response) => - response.request().method() === 'POST' && - response.url().includes(`/sessions/${sessionId}/message`) - ); - await composer.press('Enter'); - if (!(await submission).ok()) { - throw new Error('Web Provider recovery prompt submission failed'); - } - - await waitFor( - () => - probe?.events.some( - (event) => - event.type === 'provider.retry' && - event.properties.phase === 'scheduled' && - event.properties.attempt === 1 && - event.properties.maxRetries === 12 && - event.properties.mode === 'bounded_foreground' - ) === true, - 'Web SSE did not project the first bounded Provider retry', - 60_000 - ); - await waitFor( - () => - probe?.events.some( - (event) => - event.type === 'provider.circuit' && - event.properties.phase === 'waiting' && - event.properties.retryAfterMs === CIRCUIT_OPEN_MS - ) === true, - 'Web SSE did not project shared Provider circuit waiting', - 30_000 - ); - const recoveryBanner = page.locator('[data-provider-recovery-banner]'); - await recoveryBanner.waitFor({ - state: 'visible', - timeout: 10_000, - }); - expect(await recoveryBanner.textContent()).toContain( - 'Provider rate limited · waiting for recovery probe' - ); - reconnectProbe = await openSessionEventProbe(origin, sessionId, input.workspace); - expect(reconnectProbe.events[0]).toMatchObject({ - type: 'connected', - properties: { - providerRecovery: expect.objectContaining({ - snapshot: expect.objectContaining({ activity: 'circuit_open' }), - }), - }, - }); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.locator('textarea[data-blade-composer]').waitFor({ state: 'visible' }); - const reconnectedRecoveryBanner = page.locator('[data-provider-recovery-banner]'); - await reconnectedRecoveryBanner.waitFor({ - state: 'visible', - timeout: 10_000, - }); - expect(await reconnectedRecoveryBanner.textContent()).toContain( - 'Provider rate limited · waiting for recovery probe' - ); - await page.locator('[data-provider-recovery-stop]').waitFor({ - state: 'visible', - timeout: 10_000, - }); - const secondarySessionId = await createWebSession(origin, input.workspace); - secondaryProbe = await openSessionEventProbe( - origin, - secondarySessionId, - input.workspace - ); - const secondarySubmittedAt = Date.now(); - const secondarySubmission = await fetch( - `${origin}/sessions/${secondarySessionId}/message`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - content: input.secondaryPrompt, - permissionMode: 'yolo', - }), - } - ); - if (!secondarySubmission.ok) { - throw new Error( - `Secondary Web Provider circuit prompt failed: ${secondarySubmission.status}` - ); - } - await waitFor( - () => - secondaryProbe?.events.some( - (event) => - event.type === 'provider.circuit' && event.properties.phase === 'waiting' - ) === true, - 'Secondary Web Session did not wait on the shared Provider circuit', - 30_000 - ); - await waitFor( - () => - probe?.events.some( - (event) => - event.type === 'provider.retry' && - event.properties.phase === 'recovered' && - event.properties.attempt === 1 - ) === true, - 'Web SSE did not project Provider recovery', - 120_000 - ); - await waitFor( - () => { - const phases = [...(probe?.events ?? []), ...(secondaryProbe?.events ?? [])] - .filter((event) => event.type === 'provider.circuit') - .map((event) => event.properties.phase); - return phases.includes('probe') && phases.includes('closed'); - }, - 'Web SSE did not project Provider circuit probe and close', - 120_000 - ); - await page.getByText(input.marker, { exact: true }).waitFor({ - state: 'visible', - timeout: 180_000, - }); - let secondaryTranscript = ''; - let secondaryTranscriptPath = ''; - await waitFor( - async () => { - try { - secondaryTranscriptPath = findSessionTranscript( - input.storageRoot, - secondarySessionId - ); - secondaryTranscript = await readFile(secondaryTranscriptPath, 'utf8'); - return ( - finalAssistantText(readSessionEvents(secondaryTranscriptPath)) === - input.secondaryMarker - ); - } catch { - return false; - } - }, - 'Secondary Web Session did not complete after circuit close', - 180_000 - ); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page.locator('textarea[data-blade-composer]').waitFor({ state: 'visible' }); - await page.getByText(input.marker, { exact: true }).waitFor({ - state: 'visible', - timeout: 30_000, - }); - expect(consoleErrors).toEqual([]); - const html = await page.content(); - const eventOutput = JSON.stringify({ - primary: probe.events, - secondary: secondaryProbe.events, - reconnect: reconnectProbe.events, - }); - const providerEvents = [...probe.events, ...secondaryProbe.events].filter( - (event) => event.type === 'provider.retry' || event.type === 'provider.circuit' - ); - const recoveryEvents = [ - ...probe.events, - ...secondaryProbe.events, - ...reconnectProbe.events, - ].filter((event) => event.type === 'provider.recovery'); - const activityEvents = [...probe.events, ...secondaryProbe.events].filter( - (event) => event.type === 'turn.activity' - ); - const providerProbeCount = providerEvents.filter( - (event) => event.type === 'provider.circuit' && event.properties.phase === 'probe' - ).length; - assertNoSecrets( - { - output, - html, - primaryEvents: probe.events, - secondaryEvents: secondaryProbe.events, - reconnectEvents: reconnectProbe.events, - secondaryTranscript, - }, - [input.secret] - ); - - await probe.close(); - probe = undefined; - await secondaryProbe.close(); - secondaryProbe = undefined; - await reconnectProbe.close(); - reconnectProbe = undefined; - await browser.close(); - browser = undefined; - child.kill('SIGTERM'); - const exit = await waitForChildExit(child); - if (exit.signal || exit.code !== 0) { - throw new Error( - `Web Provider recovery exited ${exit.code ?? exit.signal}: ${output.replaceAll( - input.secret, - '[redacted]' - )}` - ); - } - return { - sessionId, - secondarySessionId, - secondarySubmittedAt, - providerProbeCount, - sawBoundedForegroundRecovery: providerEvents.some( - (event) => event.properties.mode === 'bounded_foreground' - ), - sawProviderLifecycle: providerEvents.length > 0, - sawProviderRecovery: - recoveryEvents.some( - (event) => - event.properties.recovery && - /retry_attempt|retry_wait|circuit_open|circuit_probe/.test( - JSON.stringify(event.properties.recovery) - ) - ) && - recoveryEvents.some( - (event) => - event.properties.recovery && - JSON.stringify(event.properties.recovery).includes('"snapshot":null') - ), - sawTurnActivity: - activityEvents.some((event) => - JSON.stringify(event.properties.activity).includes('executing_tools') - ) && - activityEvents.some((event) => - JSON.stringify(event.properties.activity).includes('"snapshot":null') - ), - output: `${output}\n${html}`.slice(-MAX_CAPTURE_CHARS), - protocolOutput: eventOutput.slice(-MAX_CAPTURE_CHARS), - }; - } finally { - await probe?.close().catch(() => undefined); - await reconnectProbe?.close().catch(() => undefined); - await secondaryProbe?.close().catch(() => undefined); - await browser?.close().catch(() => undefined); - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); - await waitForChildExit(child, 10_000).catch(() => undefined); - } - } -} - -function toolCallNames(events: ReturnType): string[] { - return events.flatMap((event) => { - if (event.type !== 'part_created' || event.data.partType !== 'tool_call') { - return []; - } - const payload = event.data.payload; - return payload && - typeof payload === 'object' && - !Array.isArray(payload) && - typeof payload.toolName === 'string' - ? [payload.toolName] - : []; - }); -} - -describe - .skipIf(!isRealApiTestEnabled()) - .sequential('authoritative Provider rate-limit cooldown release matrix', () => { - for (const { model, surface } of matrix) { - it(`${model.model} × ${surface}`, async (context) => { - expect(frameworkRetryBudget(context)).toBe(0); - const root = await mkdtemp( - path.join( - os.tmpdir(), - `blade-rate-limit-cooldown-${safeSlug(model.model)}-${surface}-` - ) - ); - const home = path.join(root, 'home'); - const storage = path.join(root, 'storage'); - const workspaceInput = path.join(root, 'workspace'); - const proxy = await startRecoveryProxy( - model.baseURL ?? 'https://api.deepseek.com' - ); - let sessionId = `provider-recovery-${safeSlug( - model.model - )}-${surface}-${Date.now()}`; - const marker = `PROVIDER_RECOVERY_OK_${Date.now()}`; - const secondaryMarker = `PROVIDER_CIRCUIT_SHARED_OK_${Date.now()}`; - const prompt = [ - '[PASTE: authoritative Provider rate-limit cooldown]', - 'Read src/add.js and test/add.test.js. Use Edit exactly once to replace ' + - '"return left - right;" with "return left + right;" in src/add.js.', - 'Do not use Write or any other mutation tool. Then call Bash exactly once ' + - 'with command "npm test".', - createSplitPtyMarkerInstruction(marker), - ].join('\n'); - if (prompt.includes(marker)) { - throw new Error('Rate-limit cooldown final marker contaminated the prompt'); - } - const secondaryPrompt = createSplitPtyMarkerInstruction(secondaryMarker); - if (secondaryPrompt.includes(secondaryMarker)) { - throw new Error( - 'Secondary rate-limit cooldown marker contaminated the prompt' - ); - } - try { - await Promise.all([ - mkdir(home, { recursive: true }), - mkdir(storage, { recursive: true }), - mkdir(workspaceInput, { recursive: true }), - ]); - const workspace = await initializeWorkspace(workspaceInput); - await writeRuntimeConfig(home, model, proxy.baseURL); - - let evidence: SurfaceEvidence; - if (surface === 'headless') { - evidence = await runHeadless({ - workspace, - home, - storageRoot: storage, - sessionId, - prompt, - marker, - secret: model.apiKey, - }); - } else if (surface === 'acp') { - evidence = await runSubprocessRunner({ - runner: acpRunner, - envName: 'BLADE_FOREGROUND_PROVIDER_RECOVERY_ACP_INPUT', - payload: { - cliEntry, - workspace, - home, - storageRoot: storage, - prompt, - marker, - secondaryPrompt, - secondaryMarker, - secret: model.apiKey, - privateMarker: proxy.privateBodyMarker, - expectRateLimitCooldown: true, - expectTurnActivity: true, - }, - timeoutMs: 240_000, - }); - sessionId = evidence.sessionId; - } else if (surface === 'pty') { - evidence = await runSubprocessRunner({ - runner: ptyRunner, - envName: 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT', - payload: { - cliEntry, - workspace, - home, - storageRoot: storage, - sessionId, - prompt, - marker, - secret: model.apiKey, - privateMarker: proxy.privateBodyMarker, - recoveryWaitText: 'Provider 请求受限,等待恢复探测', - expectToolActivity: true, - expectTerminalClear: true, - }, - timeoutMs: 480_000, - }); - } else { - evidence = await runWeb({ - workspace, - home, - storageRoot: storage, - prompt, - marker, - secondaryPrompt, - secondaryMarker, - secret: model.apiKey, - }); - sessionId = evidence.sessionId; - } - - expect(proxy.injectedFailures()).toBe(INJECTED_FAILURES); - expect(proxy.requestCount()).toBeGreaterThanOrEqual(INJECTED_FAILURES + 1); - expect(proxy.forwardedRequests()).toBeGreaterThanOrEqual(1); - const circuitOpenedAt = proxy.circuitOpenedAt(); - const firstPostOpenRequestAt = proxy.requestStartedAt()[INJECTED_FAILURES]; - expect(circuitOpenedAt).toBeTypeOf('number'); - expect(firstPostOpenRequestAt).toBeTypeOf('number'); - expect( - (firstPostOpenRequestAt ?? 0) - (circuitOpenedAt ?? 0) - ).toBeGreaterThanOrEqual(CIRCUIT_OPEN_MS - 50); - if (surface === 'acp' || surface === 'web') { - expect(proxy.forwardedRequests()).toBeGreaterThanOrEqual(2); - expect(evidence.secondarySessionId).toBeTypeOf('string'); - expect(evidence.secondarySubmittedAt).toBeTypeOf('number'); - expect( - proxy - .requestStartedAt() - .filter( - (startedAt) => - startedAt >= (evidence.secondarySubmittedAt ?? 0) && - startedAt < (circuitOpenedAt ?? 0) + CIRCUIT_OPEN_MS - 50 - ) - ).toHaveLength(0); - expect(evidence.providerProbeCount).toBe(1); - const secondaryTranscriptPath = findSessionTranscript( - storage, - evidence.secondarySessionId as string - ); - const secondaryTranscript = await readFile(secondaryTranscriptPath, 'utf8'); - const secondaryEvents = readSessionEvents(secondaryTranscriptPath); - expect(finalAssistantText(secondaryEvents)).toBe(secondaryMarker); - assertNoSecrets({ secondaryTranscript }, [ - model.apiKey, - proxy.privateBodyMarker, - ]); - } - if (surface === 'pty') { - expect(evidence.sawProviderRecoveryWait).toBe(true); - expect(evidence.sawProviderRecoveryProbe).toBe(true); - expect(evidence.sawToolActivity).toBe(true); - expect(evidence.terminalClearSeen).toBe(true); - } else { - const protocolOutput = evidence.protocolOutput ?? evidence.output; - expect( - evidence.sawBoundedForegroundRecovery ?? - protocolOutput.includes('bounded_foreground') - ).toBe(true); - expect( - evidence.sawProviderLifecycle ?? - /provider[_./]circuit|blade\/providerCircuit/.test(protocolOutput) - ).toBe(true); - expect( - evidence.sawProviderRecovery ?? - /provider[_./]recovery|blade\/providerRecovery/.test(protocolOutput) - ).toBe(true); - if (surface === 'acp' || surface === 'web') { - expect(evidence.sawTurnActivity).toBe(true); - } - } - if (surface === 'acp') { - expect(evidence.terminalReleaseCount).toBeGreaterThanOrEqual(1); - } - - const transcriptPath = findSessionTranscript(storage, sessionId); - const transcript = await readFile(transcriptPath, 'utf8'); - const events = readSessionEvents(transcriptPath); - const toolNames = toolCallNames(events); - expect( - toolNames.filter((name) => ['Edit', 'Write', 'ApplyPatch'].includes(name)) - ).toEqual(['Edit']); - expect(toolNames.filter((name) => name === 'Bash')).toHaveLength(1); - expect(finalAssistantText(events)).toBe(marker); - expect( - await readFile(path.join(workspace, 'src', 'add.js'), 'utf8') - ).toContain('return left + right;'); - const verification = await execFileAsync(process.execPath, ['--test'], { - cwd: workspace, - timeout: 30_000, - }); - expect(verification.stdout).toContain('pass 1'); - const diff = await execFileAsync('git', ['diff', '--name-only'], { - cwd: workspace, - }); - expect(diff.stdout.trim()).toBe('src/add.js'); - assertNoSecrets( - { - evidence, - transcript, - }, - [model.apiKey, proxy.privateBodyMarker] - ); - for (const process of evidence.processes ?? []) { - expect(processIdentityMatches(process.pid, process.identity)).toBe(false); - } - } finally { - await proxy.close(); - await removeTestDirectory(root); - } - }, 540_000); - } - }); +defineProviderRecoveryTrajectory('rateLimit'); diff --git a/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts b/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts new file mode 100644 index 000000000..1baf9f3f3 --- /dev/null +++ b/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts @@ -0,0 +1,1371 @@ +import { type ChildProcess, execFile, spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, realpath, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { createServer as createNetServer } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { type Browser, chromium, type Page } from 'playwright'; +import { describe, expect, it, type TestContext } from 'vitest'; +import { + type ProcessIdentity, + processIdentityMatches, +} from '../../../src/utils/process/ProcessIdentity.js'; +import { + createSplitPtyMarkerInstruction, + isCompleteRawPtyMarkerEvidence, +} from '../../support/foregroundBoundedOutputPtyDriver.js'; +import { removeTestDirectory } from '../../support/helpers/removeTestDirectory.js'; +import { + assertNoSecrets, + finalAssistantText, + findSessionTranscript, + readSessionEvents, +} from './sessionForkTrajectoryHarness.js'; +import { + buildRealApiRuntimeConfig, + isRealApiTestEnabled, + isReleaseMatrix, + releaseBlockingSurfaces, + resolveRequiredDeepSeekQualificationModels, + type TestModelConfig, +} from './testConfig.js'; + +const execFileAsync = promisify(execFile); +const allSurfaces = ['headless', 'acp', 'pty', 'web'] as const; +type ProviderRecoverySurface = (typeof allSurfaces)[number]; + +interface ProviderRecoveryScenario { + title: string; + surfaces: readonly ProviderRecoverySurface[]; + expectedCells: number; + requireReleaseMatrix: boolean; + injectedFailures: number; + failureStatus: 429 | 503; + failureType: 'rate_limit_error' | 'server_error'; + failureReason: 'rate_limit' | 'server_error'; + retryAfterMs(failure: number): number; + privateBodyMarker: string; + packageName: string; + rootPrefix: string; + sessionPrefix: string; + markerPrefix: string; + secondaryMarkerPrefix: string; + promptLabel: string; + contaminationLabel: string; + secondaryContaminationLabel: string; + retryAttempts: readonly number[]; + recoveredAttempt: number; + recoveryActivities: readonly string[]; + webBannerText: string; + frameworkRetryDisabled: boolean; + expectTurnActivity: boolean; + acpPayload: Readonly>; + ptyPayload: Readonly>; + expectPtyActivity: boolean; +} + +const scenarios = { + recovery: { + title: 'bounded foreground Provider recovery release matrix', + surfaces: releaseBlockingSurfaces(allSurfaces), + expectedCells: isReleaseMatrix() ? 6 : 8, + requireReleaseMatrix: false, + injectedFailures: 4, + failureStatus: 503, + failureType: 'server_error', + failureReason: 'server_error', + retryAfterMs: (failure) => (failure === 4 ? 2_000 : 0), + privateBodyMarker: 'PRIVATE_FOREGROUND_RECOVERY_BODY_MUST_NOT_SURFACE', + packageName: 'blade-foreground-provider-recovery', + rootPrefix: 'blade-provider-recovery', + sessionPrefix: 'provider-recovery', + markerPrefix: 'PROVIDER_RECOVERY_OK', + secondaryMarkerPrefix: 'PROVIDER_CIRCUIT_SHARED_OK', + promptLabel: 'bounded foreground provider recovery', + contaminationLabel: 'Provider recovery final marker contaminated the prompt', + secondaryContaminationLabel: + 'Secondary Provider recovery marker contaminated the prompt', + retryAttempts: [1, 2, 3, 4], + recoveredAttempt: 4, + recoveryActivities: ['retry_attempt', 'circuit_open', 'circuit_probe'], + webBannerText: 'Provider isolated', + frameworkRetryDisabled: false, + expectTurnActivity: false, + acpPayload: {}, + ptyPayload: {}, + expectPtyActivity: false, + }, + rateLimit: { + title: 'authoritative Provider rate-limit cooldown release matrix', + surfaces: allSurfaces, + expectedCells: 8, + requireReleaseMatrix: true, + injectedFailures: 1, + failureStatus: 429, + failureType: 'rate_limit_error', + failureReason: 'rate_limit', + retryAfterMs: () => 2_000, + privateBodyMarker: 'PRIVATE_RATE_LIMIT_COOLDOWN_BODY_MUST_NOT_SURFACE', + packageName: 'blade-rate-limit-cooldown', + rootPrefix: 'blade-rate-limit-cooldown', + sessionPrefix: 'rate-limit-cooldown', + markerPrefix: 'PROVIDER_RECOVERY_OK', + secondaryMarkerPrefix: 'PROVIDER_CIRCUIT_SHARED_OK', + promptLabel: 'authoritative Provider rate-limit cooldown', + contaminationLabel: 'Rate-limit cooldown final marker contaminated the prompt', + secondaryContaminationLabel: + 'Secondary rate-limit cooldown marker contaminated the prompt', + retryAttempts: [1], + recoveredAttempt: 1, + recoveryActivities: ['circuit_open', 'circuit_probe'], + webBannerText: 'Provider rate limited · waiting for recovery probe', + frameworkRetryDisabled: true, + expectTurnActivity: true, + acpPayload: { + expectRateLimitCooldown: true, + expectTurnActivity: true, + }, + ptyPayload: { + recoveryWaitText: 'Provider 请求受限,等待恢复探测', + expectToolActivity: true, + expectTerminalClear: true, + }, + expectPtyActivity: true, + }, +} satisfies Record; + +export type ProviderRecoveryScenarioName = keyof typeof scenarios; + +const cliEntry = path.resolve(import.meta.dirname, '../../../dist/blade.js'); +const acpRunner = path.resolve( + import.meta.dirname, + '../../support/foregroundProviderRecoveryAcpRunner.ts' +); +const ptyRunner = path.resolve( + import.meta.dirname, + '../../support/foregroundProviderRecoveryPtyRunner.ts' +); +const MAX_CAPTURE_CHARS = 256_000; +const CIRCUIT_OPEN_MS = 2_000; + +interface SurfaceEvidence { + sessionId: string; + output: string; + protocolOutput?: string; + finalMarkerSeen?: boolean; + secretSeen?: boolean; + processes?: Array<{ pid: number; identity: ProcessIdentity }>; + terminalReleaseCount?: number; + secondarySessionId?: string; + secondarySubmittedAt?: number; + providerProbeCount?: number; + sawBoundedForegroundRecovery?: boolean; + sawProviderLifecycle?: boolean; + sawProviderRecovery?: boolean; + sawProviderRecoveryWait?: boolean; + sawProviderRecoveryProbe?: boolean; + sawTurnActivity?: boolean; + sawToolActivity?: boolean; + terminalClearSeen?: boolean; +} + +interface SessionEventProbe { + events: Array<{ type: string; properties: Record }>; + close(): Promise; +} + +function safeSlug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function frameworkRetryBudget(context: TestContext): number { + const retry = context.task.retry; + return typeof retry === 'number' ? retry : (retry?.count ?? 0); +} + +function appendTail(current: string, chunk: Buffer | string): string { + return `${current}${chunk.toString()}`.slice(-MAX_CAPTURE_CHARS); +} + +async function waitFor( + predicate: () => boolean | Promise, + message: string, + timeoutMs = 120_000 +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + if (await predicate()) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(message, { cause: lastError }); +} + +function waitForChildExit( + child: ChildProcess, + timeoutMs = 30_000 +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error('Provider recovery surface process did not exit')); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + child.off('error', onError); + child.off('exit', onExit); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + resolve({ code, signal }); + }; + child.once('error', onError); + child.once('exit', onExit); + }); +} + +function upstreamUrl(baseUrl: string, requestUrl: string | undefined): URL { + const target = new URL(baseUrl); + const incoming = new URL(requestUrl ?? '/', 'http://127.0.0.1'); + target.pathname = `${target.pathname.replace(/\/$/, '')}/${incoming.pathname.replace( + /^\//, + '' + )}`; + target.search = incoming.search; + return target; +} + +async function readRequestBody( + request: import('node:http').IncomingMessage +): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +async function startRecoveryProxy(baseUrl: string, scenario: ProviderRecoveryScenario) { + let requestCount = 0; + let injectedFailures = 0; + let forwardedRequests = 0; + let circuitOpenedAt: number | undefined; + const requestStartedAt: number[] = []; + const privateBodyMarker = scenario.privateBodyMarker; + const server = createServer((request, response) => { + void (async () => { + const body = await readRequestBody(request); + requestCount++; + requestStartedAt.push(Date.now()); + if (injectedFailures < scenario.injectedFailures) { + injectedFailures++; + response.writeHead(scenario.failureStatus, { + 'content-type': 'application/json', + 'retry-after-ms': String(scenario.retryAfterMs(injectedFailures)), + }); + response.end( + JSON.stringify({ + error: { + type: scenario.failureType, + message: privateBodyMarker, + }, + }) + ); + if (injectedFailures === scenario.injectedFailures) { + circuitOpenedAt = Date.now(); + } + return; + } + + forwardedRequests++; + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (!value || name === 'host' || name === 'content-length') continue; + headers.set(name, Array.isArray(value) ? value.join(', ') : value); + } + const upstream = await fetch(upstreamUrl(baseUrl, request.url), { + method: request.method ?? 'POST', + headers, + body: + request.method === 'GET' || request.method === 'HEAD' + ? undefined + : body.toString('utf8'), + redirect: 'manual', + }); + const responseHeaders: Record = {}; + upstream.headers.forEach((value, name) => { + if ( + ![ + 'connection', + 'content-encoding', + 'content-length', + 'transfer-encoding', + ].includes(name) + ) { + responseHeaders[name] = value; + } + }); + response.writeHead(upstream.status, responseHeaders); + if (upstream.body) { + const reader = upstream.body.getReader(); + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + response.write(Buffer.from(chunk.value)); + } + } + response.end(); + })().catch((error: unknown) => { + if (response.headersSent) { + response.destroy(error instanceof Error ? error : undefined); + return; + } + response.writeHead(502, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: { type: 'proxy_error' } })); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + return { + baseURL: `http://127.0.0.1:${address.port}`, + privateBodyMarker, + requestCount: () => requestCount, + injectedFailures: () => injectedFailures, + forwardedRequests: () => forwardedRequests, + circuitOpenedAt: () => circuitOpenedAt, + requestStartedAt: () => [...requestStartedAt], + close: async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +async function initializeWorkspace( + workspace: string, + packageName: string +): Promise { + await Promise.all([ + mkdir(path.join(workspace, 'src'), { recursive: true }), + mkdir(path.join(workspace, 'test'), { recursive: true }), + ]); + await writeFile( + path.join(workspace, 'package.json'), + `${JSON.stringify( + { + name: packageName, + private: true, + type: 'module', + scripts: { test: 'node --test' }, + }, + null, + 2 + )}\n` + ); + await writeFile( + path.join(workspace, 'src', 'add.js'), + 'export function add(left, right) {\n return left - right;\n}\n' + ); + await writeFile( + path.join(workspace, 'test', 'add.test.js'), + [ + "import assert from 'node:assert/strict';", + "import test from 'node:test';", + "import { add } from '../src/add.js';", + '', + "test('adds two numbers', () => {", + ' assert.equal(add(4, 3), 7);', + '});', + '', + ].join('\n') + ); + await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); + await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { + cwd: workspace, + }); + await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { + cwd: workspace, + }); + await execFileAsync('git', ['add', '.'], { cwd: workspace }); + await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); + return realpath(workspace); +} + +async function writeRuntimeConfig( + home: string, + model: TestModelConfig, + baseURL: string +): Promise { + const config = buildRealApiRuntimeConfig({ ...model, baseURL }); + const modelsWithoutRetryOverride = config.models.map((entry) => { + const overrides = { ...entry.overrides }; + delete overrides.maxRetries; + return { ...entry, overrides }; + }); + const bladeHome = path.join(home, '.blade'); + const skillCreator = path.join(bladeHome, 'skills', 'skill-creator'); + await Promise.all([ + mkdir(bladeHome, { recursive: true }), + mkdir(skillCreator, { recursive: true }), + ]); + await Promise.all([ + writeFile( + path.join(bladeHome, 'config.json'), + `${JSON.stringify( + { + currentModelId: config.currentModelId, + models: modelsWithoutRetryOverride, + modelProviders: config.modelProviders, + permissionMode: 'yolo', + providerForegroundRecoveryMs: 120_000, + providerCircuitBreakerOpenMs: CIRCUIT_OPEN_MS, + hooks: { enabled: false }, + disableAllHooks: true, + mcpServers: {}, + }, + null, + 2 + )}\n`, + { mode: 0o600 } + ), + writeFile( + path.join(skillCreator, 'SKILL.md'), + [ + '---', + 'name: skill-creator', + 'description: Local deterministic qualification fixture.', + '---', + '', + '# Fixture Skill Creator', + '', + ].join('\n') + ), + ]); +} + +function childEnvironment(home: string, storageRoot: string): NodeJS.ProcessEnv { + return { + ...process.env, + HOME: home, + BLADE_STORAGE_ROOT: storageRoot, + BLADE_AUTO_MEMORY: '0', + BLADE_TELEMETRY_DISABLED: '1', + TERM: 'xterm-256color', + }; +} + +function parseHeadlessContent(stdout: string): string { + return stdout + .split(/\r?\n/) + .filter(Boolean) + .flatMap((line) => { + try { + const event = JSON.parse(line) as Record; + return event.type === 'content_delta' && typeof event.delta === 'string' + ? [event.delta] + : []; + } catch { + return []; + } + }) + .join(''); +} + +function assertHeadlessRetry(stdout: string, scenario: ProviderRecoveryScenario): void { + const events = stdout + .split(/\r?\n/) + .filter(Boolean) + .flatMap((line) => { + try { + const event = JSON.parse(line) as Record; + return [event]; + } catch { + return []; + } + }); + const retryEvents = events.filter((event) => event.type === 'provider_retry'); + const circuitEvents = events.filter((event) => event.type === 'provider_circuit'); + const recoveryEvents = events.filter((event) => event.type === 'provider_recovery'); + const activityEvents = events.filter((event) => event.type === 'turn_activity'); + expect( + retryEvents + .filter((event) => event.phase === 'attempt') + .map((event) => event.attempt) + ).toEqual(scenario.retryAttempts); + expect(retryEvents).toContainEqual( + expect.objectContaining({ + phase: 'recovered', + attempt: scenario.recoveredAttempt, + max_retries: 12, + mode: 'bounded_foreground', + recovery_budget_ms: 120_000, + }) + ); + expect(circuitEvents.map((event) => event.phase)).toEqual([ + 'opened', + 'waiting', + 'probe', + 'closed', + ]); + expect(circuitEvents[1]).toMatchObject({ + phase: 'waiting', + reason: scenario.failureReason, + status_code: scenario.failureStatus, + retry_after_ms: CIRCUIT_OPEN_MS, + open_duration_ms: CIRCUIT_OPEN_MS, + }); + expect(recoveryEvents).toEqual( + expect.arrayContaining([ + ...scenario.recoveryActivities.map((activity) => + expect.objectContaining({ + snapshot: expect.objectContaining({ activity }), + }) + ), + expect.objectContaining({ snapshot: null }), + ]) + ); + const recoveryGenerations = new Set( + recoveryEvents + .map((event) => event.generation) + .filter((generation): generation is string => typeof generation === 'string') + ); + expect(recoveryGenerations).toHaveLength(1); + const recoveryRevisions = recoveryEvents + .map((event) => event.revision) + .filter((revision): revision is number => typeof revision === 'number'); + expect(recoveryRevisions).toEqual( + [...recoveryRevisions].sort((left, right) => left - right) + ); + if (scenario.expectTurnActivity) { + expect(activityEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + snapshot: expect.objectContaining({ + phase: 'executing_tools', + active_tools: expect.arrayContaining([ + expect.objectContaining({ name: 'Bash' }), + ]), + }), + }), + expect.objectContaining({ snapshot: null }), + ]) + ); + } +} + +async function runHeadless(input: { + workspace: string; + home: string; + storageRoot: string; + sessionId: string; + prompt: string; + marker: string; + secret: string; + scenario: ProviderRecoveryScenario; +}): Promise { + const child = spawn( + process.execPath, + [ + cliEntry, + '--headless', + '--output-format', + 'jsonl', + '--trust-workspace', + '--permission-mode', + 'yolo', + '--max-turns', + '8', + '--session-id', + input.sessionId, + '--allowed-tools', + 'Read,Edit,Bash', + '--no-verification-agent', + input.prompt, + ], + { + cwd: input.workspace, + env: childEnvironment(input.home, input.storageRoot), + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + stdout = appendTail(stdout, chunk); + }); + child.stderr?.on('data', (chunk) => { + stderr = appendTail(stderr, chunk); + }); + try { + const exit = await waitForChildExit(child, 180_000); + const diagnostic = `${stdout}\n${stderr}`.replaceAll(input.secret, '[redacted]'); + if (exit.signal || exit.code !== 0) { + throw new Error( + `Headless Provider recovery exited ${exit.code ?? exit.signal}: ${diagnostic}` + ); + } + assertHeadlessRetry(stdout, input.scenario); + expect(parseHeadlessContent(stdout)).toContain(input.marker); + return { sessionId: input.sessionId, output: `${stdout}\n${stderr}` }; + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } +} + +async function runSubprocessRunner(input: { + runner: string; + envName: + | 'BLADE_FOREGROUND_PROVIDER_RECOVERY_ACP_INPUT' + | 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT'; + payload: Record; + timeoutMs: number; +}): Promise { + const encoded = Buffer.from(JSON.stringify(input.payload), 'utf8').toString('base64'); + let stdout = ''; + let stderr = ''; + try { + const result = await execFileAsync('bun', [input.runner], { + cwd: path.resolve(import.meta.dirname, '../../..'), + env: { ...process.env, [input.envName]: encoded }, + timeout: input.timeoutMs, + maxBuffer: 1024 * 1024, + killSignal: 'SIGKILL', + }); + stdout = result.stdout; + stderr = result.stderr; + } catch (error) { + const failed = error as Error & { stdout?: string; stderr?: string }; + stdout = failed.stdout ?? stdout; + stderr = failed.stderr ?? stderr; + } + let parsed: SurfaceEvidence & { success?: unknown; error?: unknown }; + try { + parsed = JSON.parse(stdout.trim()) as typeof parsed; + } catch (error) { + throw new Error( + `Provider recovery runner emitted invalid JSON: ${stderr.slice(-8_000)}`, + { cause: error } + ); + } + if ( + parsed.success !== true || + (input.envName === 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT' && + !isCompleteRawPtyMarkerEvidence(parsed)) + ) { + throw new Error(`Provider recovery runner failed: ${String(parsed.error)}`); + } + return parsed; +} + +async function reservePort(): Promise { + const server = createNetServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Unable to reserve Provider recovery Web port'); + } + const port = address.port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +async function waitForHttp(url: string): Promise { + await waitFor( + async () => { + try { + return (await fetch(url)).ok; + } catch { + return false; + } + }, + `Timed out waiting for ${url}`, + 20_000 + ); +} + +async function createWebSession(origin: string, projectPath: string): Promise { + const response = await fetch(`${origin}/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + projectPath, + title: 'Bounded foreground Provider recovery', + }), + }); + if (!response.ok) { + throw new Error(`Web Provider recovery Session failed: ${response.status}`); + } + const created = (await response.json()) as { sessionId?: unknown }; + if (typeof created.sessionId !== 'string') { + throw new Error('Web Provider recovery Session returned no ID'); + } + return created.sessionId; +} + +async function openSessionEventProbe( + origin: string, + sessionId: string, + projectPath: string +): Promise { + const controller = new AbortController(); + const url = new URL(`${origin}/sessions/${sessionId}/events`); + url.searchParams.set('projectPath', projectPath); + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok || !response.body) { + controller.abort(); + throw new Error(`Web Provider recovery SSE failed: ${response.status}`); + } + const events: SessionEventProbe['events'] = []; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let readingError: unknown; + const consume = (frame: string) => { + const data = frame + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n'); + if (!data) return; + const event = JSON.parse(data) as { type?: unknown; properties?: unknown }; + if ( + typeof event.type === 'string' && + event.properties && + typeof event.properties === 'object' && + !Array.isArray(event.properties) + ) { + events.push({ + type: event.type, + properties: event.properties as Record, + }); + } + }; + const reading = (async () => { + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }); + const frames = buffer.split(/\r?\n\r?\n/); + buffer = frames.pop() ?? ''; + for (const frame of frames) consume(frame); + } + } catch (error) { + if (!controller.signal.aborted) readingError = error; + } + })(); + await waitFor( + () => events.some((event) => event.type === 'connected'), + 'Web Provider recovery SSE did not connect', + 20_000 + ); + return { + events, + async close() { + controller.abort(); + await reader.cancel().catch(() => undefined); + await reading; + if (readingError) { + throw new Error('Web Provider recovery SSE read failed', { + cause: readingError, + }); + } + }, + }; +} + +async function openWebSessionPage( + browser: Browser, + origin: string, + sessionId: string, + projectPath: string +): Promise { + const page = await browser.newPage(); + const navigation = new URL(origin); + navigation.searchParams.set('session', sessionId); + navigation.searchParams.set('project', projectPath); + await page.goto(navigation.href, { waitUntil: 'domcontentloaded' }); + await page.locator('textarea[data-blade-composer]').waitFor({ state: 'visible' }); + const permissionMode = page.locator('[data-blade-permission-mode]'); + await permissionMode.waitFor({ state: 'visible' }); + if ((await permissionMode.getAttribute('data-blade-permission-mode')) !== 'yolo') { + await permissionMode.click(); + await page.locator('[data-blade-permission-option="yolo"]').click(); + await page.locator('[data-blade-yolo-confirm]').click(); + await page.waitForFunction( + () => + document + .querySelector('[data-blade-permission-mode]') + ?.getAttribute('data-blade-permission-mode') === 'yolo' + ); + } + return page; +} + +async function runWeb(input: { + workspace: string; + home: string; + storageRoot: string; + prompt: string; + marker: string; + secondaryPrompt: string; + secondaryMarker: string; + secret: string; + scenario: ProviderRecoveryScenario; +}): Promise { + const port = await reservePort(); + const child = spawn( + process.execPath, + [cliEntry, 'serve', '--hostname', '127.0.0.1', '--port', String(port)], + { + cwd: input.workspace, + env: childEnvironment(input.home, input.storageRoot), + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + let output = ''; + child.stdout?.on('data', (chunk) => { + output = appendTail(output, chunk); + }); + child.stderr?.on('data', (chunk) => { + output = appendTail(output, chunk); + }); + let browser: Browser | undefined; + let probe: SessionEventProbe | undefined; + let reconnectProbe: SessionEventProbe | undefined; + let secondaryProbe: SessionEventProbe | undefined; + try { + const origin = `http://127.0.0.1:${port}`; + await waitForHttp(`${origin}/health`); + const sessionId = await createWebSession(origin, input.workspace); + probe = await openSessionEventProbe(origin, sessionId, input.workspace); + browser = await chromium.launch({ headless: true }); + const page = await openWebSessionPage(browser, origin, sessionId, input.workspace); + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + const composer = page.locator('textarea[data-blade-composer]'); + await composer.fill(input.prompt); + const submission = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes(`/sessions/${sessionId}/message`) + ); + await composer.press('Enter'); + if (!(await submission).ok()) { + throw new Error('Web Provider recovery prompt submission failed'); + } + + await waitFor( + () => + probe?.events.some( + (event) => + event.type === 'provider.retry' && + event.properties.phase === 'scheduled' && + event.properties.attempt === input.scenario.recoveredAttempt && + event.properties.maxRetries === 12 && + event.properties.mode === 'bounded_foreground' + ) === true, + 'Web SSE did not project the bounded Provider retry', + 60_000 + ); + await waitFor( + () => + probe?.events.some( + (event) => + event.type === 'provider.circuit' && + event.properties.phase === 'waiting' && + event.properties.retryAfterMs === CIRCUIT_OPEN_MS + ) === true, + 'Web SSE did not project shared Provider circuit waiting', + 30_000 + ); + const recoveryBanner = page.locator('[data-provider-recovery-banner]'); + await recoveryBanner.waitFor({ + state: 'visible', + timeout: 10_000, + }); + expect(await recoveryBanner.textContent()).toContain(input.scenario.webBannerText); + reconnectProbe = await openSessionEventProbe(origin, sessionId, input.workspace); + expect(reconnectProbe.events[0]).toMatchObject({ + type: 'connected', + properties: { + providerRecovery: expect.objectContaining({ + snapshot: expect.objectContaining({ activity: 'circuit_open' }), + }), + }, + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.locator('textarea[data-blade-composer]').waitFor({ state: 'visible' }); + const reconnectedRecoveryBanner = page.locator('[data-provider-recovery-banner]'); + await reconnectedRecoveryBanner.waitFor({ + state: 'visible', + timeout: 10_000, + }); + expect(await reconnectedRecoveryBanner.textContent()).toContain( + input.scenario.webBannerText + ); + await page.locator('[data-provider-recovery-stop]').waitFor({ + state: 'visible', + timeout: 10_000, + }); + const secondarySessionId = await createWebSession(origin, input.workspace); + secondaryProbe = await openSessionEventProbe( + origin, + secondarySessionId, + input.workspace + ); + const secondarySubmittedAt = Date.now(); + const secondarySubmission = await fetch( + `${origin}/sessions/${secondarySessionId}/message`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + content: input.secondaryPrompt, + permissionMode: 'yolo', + }), + } + ); + if (!secondarySubmission.ok) { + throw new Error( + `Secondary Web Provider circuit prompt failed: ${secondarySubmission.status}` + ); + } + await waitFor( + () => + secondaryProbe?.events.some( + (event) => + event.type === 'provider.circuit' && event.properties.phase === 'waiting' + ) === true, + 'Secondary Web Session did not wait on the shared Provider circuit', + 30_000 + ); + await waitFor( + () => + probe?.events.some( + (event) => + event.type === 'provider.retry' && + event.properties.phase === 'recovered' && + event.properties.attempt === input.scenario.recoveredAttempt + ) === true, + 'Web SSE did not project Provider recovery', + 120_000 + ); + await waitFor( + () => { + const phases = [...(probe?.events ?? []), ...(secondaryProbe?.events ?? [])] + .filter((event) => event.type === 'provider.circuit') + .map((event) => event.properties.phase); + return phases.includes('probe') && phases.includes('closed'); + }, + 'Web SSE did not project Provider circuit probe and close', + 120_000 + ); + await page.getByText(input.marker, { exact: true }).waitFor({ + state: 'visible', + timeout: 180_000, + }); + let secondaryTranscript = ''; + let secondaryTranscriptPath = ''; + await waitFor( + async () => { + try { + secondaryTranscriptPath = findSessionTranscript( + input.storageRoot, + secondarySessionId + ); + secondaryTranscript = await readFile(secondaryTranscriptPath, 'utf8'); + return ( + finalAssistantText(readSessionEvents(secondaryTranscriptPath)) === + input.secondaryMarker + ); + } catch { + return false; + } + }, + 'Secondary Web Session did not complete after circuit close', + 180_000 + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.locator('textarea[data-blade-composer]').waitFor({ state: 'visible' }); + await page.getByText(input.marker, { exact: true }).waitFor({ + state: 'visible', + timeout: 30_000, + }); + expect(consoleErrors).toEqual([]); + const html = await page.content(); + const eventOutput = JSON.stringify({ + primary: probe.events, + secondary: secondaryProbe.events, + reconnect: reconnectProbe.events, + }); + const providerEvents = [...probe.events, ...secondaryProbe.events].filter( + (event) => event.type === 'provider.retry' || event.type === 'provider.circuit' + ); + const recoveryEvents = [ + ...probe.events, + ...secondaryProbe.events, + ...reconnectProbe.events, + ].filter((event) => event.type === 'provider.recovery'); + const activityEvents = [...probe.events, ...secondaryProbe.events].filter( + (event) => event.type === 'turn.activity' + ); + const providerProbeCount = providerEvents.filter( + (event) => event.type === 'provider.circuit' && event.properties.phase === 'probe' + ).length; + assertNoSecrets( + { + output, + html, + primaryEvents: probe.events, + secondaryEvents: secondaryProbe.events, + reconnectEvents: reconnectProbe.events, + secondaryTranscript, + }, + [input.secret] + ); + + await probe.close(); + probe = undefined; + await secondaryProbe.close(); + secondaryProbe = undefined; + await reconnectProbe.close(); + reconnectProbe = undefined; + await browser.close(); + browser = undefined; + child.kill('SIGTERM'); + const exit = await waitForChildExit(child); + if (exit.signal || exit.code !== 0) { + throw new Error( + `Web Provider recovery exited ${exit.code ?? exit.signal}: ${output.replaceAll( + input.secret, + '[redacted]' + )}` + ); + } + return { + sessionId, + secondarySessionId, + secondarySubmittedAt, + providerProbeCount, + sawBoundedForegroundRecovery: providerEvents.some( + (event) => event.properties.mode === 'bounded_foreground' + ), + sawProviderLifecycle: providerEvents.length > 0, + sawProviderRecovery: + recoveryEvents.some( + (event) => + event.properties.recovery && + /retry_attempt|retry_wait|circuit_open|circuit_probe/.test( + JSON.stringify(event.properties.recovery) + ) + ) && + recoveryEvents.some( + (event) => + event.properties.recovery && + JSON.stringify(event.properties.recovery).includes('"snapshot":null') + ), + sawTurnActivity: + activityEvents.some((event) => + JSON.stringify(event.properties.activity).includes('executing_tools') + ) && + activityEvents.some((event) => + JSON.stringify(event.properties.activity).includes('"snapshot":null') + ), + output: `${output}\n${html}`.slice(-MAX_CAPTURE_CHARS), + protocolOutput: eventOutput.slice(-MAX_CAPTURE_CHARS), + }; + } finally { + await probe?.close().catch(() => undefined); + await reconnectProbe?.close().catch(() => undefined); + await secondaryProbe?.close().catch(() => undefined); + await browser?.close().catch(() => undefined); + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await waitForChildExit(child, 10_000).catch(() => undefined); + } + } +} + +function toolCallNames(events: ReturnType): string[] { + return events.flatMap((event) => { + if (event.type !== 'part_created' || event.data.partType !== 'tool_call') { + return []; + } + const payload = event.data.payload; + return payload && + typeof payload === 'object' && + !Array.isArray(payload) && + typeof payload.toolName === 'string' + ? [payload.toolName] + : []; + }); +} + +export function defineProviderRecoveryTrajectory( + scenarioName: ProviderRecoveryScenarioName +): void { + const scenario = scenarios[scenarioName]; + const models = isRealApiTestEnabled() + ? resolveRequiredDeepSeekQualificationModels() + : []; + const matrix = models.flatMap((model) => + scenario.surfaces.map((surface) => ({ model, surface })) + ); + if (isRealApiTestEnabled() && matrix.length !== scenario.expectedCells) { + throw new Error( + `${scenario.title} must contain ${scenario.expectedCells} cells, got ${matrix.length}` + ); + } + if (isRealApiTestEnabled() && scenario.requireReleaseMatrix && !isReleaseMatrix()) { + throw new Error(`${scenario.title} requires REAL_API_RELEASE_MATRIX=1`); + } + + describe.skipIf(!isRealApiTestEnabled()).sequential(scenario.title, () => { + for (const { model, surface } of matrix) { + it(`${model.model} × ${surface}`, async (context) => { + if (scenario.frameworkRetryDisabled) { + expect(frameworkRetryBudget(context)).toBe(0); + } + const root = await mkdtemp( + path.join( + os.tmpdir(), + `${scenario.rootPrefix}-${safeSlug(model.model)}-${surface}-` + ) + ); + const home = path.join(root, 'home'); + const storage = path.join(root, 'storage'); + const workspaceInput = path.join(root, 'workspace'); + const proxy = await startRecoveryProxy( + model.baseURL ?? 'https://api.deepseek.com', + scenario + ); + let sessionId = `${scenario.sessionPrefix}-${safeSlug( + model.model + )}-${surface}-${Date.now()}`; + const marker = `${scenario.markerPrefix}_${Date.now()}`; + const secondaryMarker = `${scenario.secondaryMarkerPrefix}_${Date.now()}`; + const prompt = [ + `[PASTE: ${scenario.promptLabel}]`, + 'Read src/add.js and test/add.test.js. Use Edit exactly once to replace ' + + '"return left - right;" with "return left + right;" in src/add.js.', + 'Do not use Write or any other mutation tool. Then call Bash exactly once ' + + 'with command "npm test".', + createSplitPtyMarkerInstruction(marker), + ].join('\n'); + if (prompt.includes(marker)) { + throw new Error(scenario.contaminationLabel); + } + const secondaryPrompt = createSplitPtyMarkerInstruction(secondaryMarker); + if (secondaryPrompt.includes(secondaryMarker)) { + throw new Error(scenario.secondaryContaminationLabel); + } + try { + await Promise.all([ + mkdir(home, { recursive: true }), + mkdir(storage, { recursive: true }), + mkdir(workspaceInput, { recursive: true }), + ]); + const workspace = await initializeWorkspace( + workspaceInput, + scenario.packageName + ); + await writeRuntimeConfig(home, model, proxy.baseURL); + + const commonRunnerInput = { + cliEntry, + workspace, + home, + storageRoot: storage, + sessionId, + prompt, + marker, + secret: model.apiKey, + privateMarker: proxy.privateBodyMarker, + }; + const surfaceRunners: Record< + ProviderRecoverySurface, + () => Promise + > = { + headless: () => + runHeadless({ + ...commonRunnerInput, + scenario, + }), + acp: () => + runSubprocessRunner({ + runner: acpRunner, + envName: 'BLADE_FOREGROUND_PROVIDER_RECOVERY_ACP_INPUT', + payload: { + ...commonRunnerInput, + secondaryPrompt, + secondaryMarker, + ...scenario.acpPayload, + }, + timeoutMs: 240_000, + }), + pty: () => + runSubprocessRunner({ + runner: ptyRunner, + envName: 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT', + payload: { + ...commonRunnerInput, + ...scenario.ptyPayload, + }, + timeoutMs: 480_000, + }), + web: () => + runWeb({ + ...commonRunnerInput, + secondaryPrompt, + secondaryMarker, + scenario, + }), + }; + const evidence = await surfaceRunners[surface](); + if (surface === 'acp' || surface === 'web') { + sessionId = evidence.sessionId; + } + + expect(proxy.injectedFailures()).toBe(scenario.injectedFailures); + expect(proxy.requestCount()).toBeGreaterThanOrEqual( + scenario.injectedFailures + 1 + ); + expect(proxy.forwardedRequests()).toBeGreaterThanOrEqual(1); + const circuitOpenedAt = proxy.circuitOpenedAt(); + const firstPostOpenRequestAt = + proxy.requestStartedAt()[scenario.injectedFailures]; + expect(circuitOpenedAt).toBeTypeOf('number'); + expect(firstPostOpenRequestAt).toBeTypeOf('number'); + expect( + (firstPostOpenRequestAt ?? 0) - (circuitOpenedAt ?? 0) + ).toBeGreaterThanOrEqual(CIRCUIT_OPEN_MS - 50); + if (surface === 'acp' || surface === 'web') { + expect(proxy.forwardedRequests()).toBeGreaterThanOrEqual(2); + expect(evidence.secondarySessionId).toBeTypeOf('string'); + expect(evidence.secondarySubmittedAt).toBeTypeOf('number'); + expect( + proxy + .requestStartedAt() + .filter( + (startedAt) => + startedAt >= (evidence.secondarySubmittedAt ?? 0) && + startedAt < (circuitOpenedAt ?? 0) + CIRCUIT_OPEN_MS - 50 + ) + ).toHaveLength(0); + expect(evidence.providerProbeCount).toBe(1); + const secondaryTranscriptPath = findSessionTranscript( + storage, + evidence.secondarySessionId as string + ); + const secondaryTranscript = await readFile(secondaryTranscriptPath, 'utf8'); + const secondaryEvents = readSessionEvents(secondaryTranscriptPath); + expect(finalAssistantText(secondaryEvents)).toBe(secondaryMarker); + assertNoSecrets({ secondaryTranscript }, [ + model.apiKey, + proxy.privateBodyMarker, + ]); + } + if (surface === 'pty') { + expect(evidence.sawProviderRecoveryWait).toBe(true); + expect(evidence.sawProviderRecoveryProbe).toBe(true); + if (scenario.expectPtyActivity) { + expect(evidence.sawToolActivity).toBe(true); + expect(evidence.terminalClearSeen).toBe(true); + } + } else { + const protocolOutput = evidence.protocolOutput ?? evidence.output; + expect( + evidence.sawBoundedForegroundRecovery ?? + protocolOutput.includes('bounded_foreground') + ).toBe(true); + expect( + evidence.sawProviderLifecycle ?? + /provider[_./]circuit|blade\/providerCircuit/.test(protocolOutput) + ).toBe(true); + expect( + evidence.sawProviderRecovery ?? + /provider[_./]recovery|blade\/providerRecovery/.test(protocolOutput) + ).toBe(true); + if ( + scenario.expectTurnActivity && + (surface === 'acp' || surface === 'web') + ) { + expect(evidence.sawTurnActivity).toBe(true); + } + } + if (surface === 'acp') { + expect(evidence.terminalReleaseCount).toBeGreaterThanOrEqual(1); + } + + const transcriptPath = findSessionTranscript(storage, sessionId); + const transcript = await readFile(transcriptPath, 'utf8'); + const events = readSessionEvents(transcriptPath); + const toolNames = toolCallNames(events); + expect( + toolNames.filter((name) => ['Edit', 'Write', 'ApplyPatch'].includes(name)) + ).toEqual(['Edit']); + expect(toolNames.filter((name) => name === 'Bash')).toHaveLength(1); + expect(finalAssistantText(events)).toBe(marker); + expect( + await readFile(path.join(workspace, 'src', 'add.js'), 'utf8') + ).toContain('return left + right;'); + const verification = await execFileAsync(process.execPath, ['--test'], { + cwd: workspace, + timeout: 30_000, + }); + expect(verification.stdout).toContain('pass 1'); + const diff = await execFileAsync('git', ['diff', '--name-only'], { + cwd: workspace, + }); + expect(diff.stdout.trim()).toBe('src/add.js'); + assertNoSecrets( + { + evidence, + transcript, + }, + [model.apiKey, proxy.privateBodyMarker] + ); + for (const process of evidence.processes ?? []) { + expect(processIdentityMatches(process.pid, process.identity)).toBe(false); + } + } finally { + await proxy.close(); + await removeTestDirectory(root); + } + }, 540_000); + } + }); +} diff --git a/packages/cli/tests/unit/foreground-provider-recovery-search-gate.test.ts b/packages/cli/tests/unit/foreground-provider-recovery-search-gate.test.ts index 42dc3ad20..ee420e9c3 100644 --- a/packages/cli/tests/unit/foreground-provider-recovery-search-gate.test.ts +++ b/packages/cli/tests/unit/foreground-provider-recovery-search-gate.test.ts @@ -5,6 +5,13 @@ function source(path: string): string { return readFileSync(new URL(path, import.meta.url), 'utf8'); } +function providerRecoveryTrajectorySource(): string { + return [ + source('../integration/real-api/foreground-provider-recovery-trajectory.test.ts'), + source('../integration/real-api/providerRecoveryTrajectoryHarness.ts'), + ].join('\n'); +} + describe('foreground Provider recovery source gate', () => { it('keeps extended recovery owned by root Agent requests', () => { const loop = source('../../src/agent/loop/executeLoopGenerator.ts'); @@ -54,9 +61,7 @@ describe('foreground Provider recovery source gate', () => { it('requires sticky raw PTY completion evidence in the release trajectory', () => { const runner = source('../support/foregroundProviderRecoveryPtyRunner.ts'); - const trajectory = source( - '../integration/real-api/foreground-provider-recovery-trajectory.test.ts' - ); + const trajectory = providerRecoveryTrajectorySource(); expect(runner).toContain('finalMarkerSeen: finalMarkerLatch.seen'); expect(runner).toContain('secretSeen: secretLatch.seen'); @@ -90,7 +95,7 @@ describe('foreground Provider recovery source gate', () => { "envName: 'BLADE_FOREGROUND_PROVIDER_RECOVERY_PTY_INPUT'" ); const ptyTimeout = trajectory.indexOf('timeoutMs: 480_000', ptyBranch); - const webBranch = trajectory.indexOf('} else {', ptyBranch); + const webBranch = trajectory.indexOf('web: () =>', ptyBranch); expect(ptyBranch).toBeGreaterThanOrEqual(0); expect(ptyTimeout).toBeGreaterThan(ptyBranch); expect(ptyTimeout).toBeLessThan(webBranch); @@ -104,9 +109,7 @@ describe('foreground Provider recovery source gate', () => { const web = source('../../web/src/components/chat/ProviderRecoveryBanner.tsx'); const acpRunner = source('../support/foregroundProviderRecoveryAcpRunner.ts'); const ptyRunner = source('../support/foregroundProviderRecoveryPtyRunner.ts'); - const trajectory = source( - '../integration/real-api/foreground-provider-recovery-trajectory.test.ts' - ); + const trajectory = providerRecoveryTrajectorySource(); expect(runtime).toContain('private readonly providerRecovery'); expect(agent).toContain('beginProviderRecovery()'); diff --git a/packages/cli/tests/unit/scripts/qualification.test.ts b/packages/cli/tests/unit/scripts/qualification.test.ts index 04305cc0e..db059c9d5 100644 --- a/packages/cli/tests/unit/scripts/qualification.test.ts +++ b/packages/cli/tests/unit/scripts/qualification.test.ts @@ -470,10 +470,17 @@ describe('production qualification contract', () => { __dirname, '../../integration/real-api/provider-rate-limit-cooldown-trajectory.test.ts' ); + const realApiHarnessPath = path.resolve( + __dirname, + '../../integration/real-api/providerRecoveryTrajectoryHarness.ts' + ); const deterministic = fs.readFileSync(deterministicPath, 'utf8'); const acpRunner = fs.readFileSync(acpRunnerPath, 'utf8'); const ptyRunner = fs.readFileSync(ptyRunnerPath, 'utf8'); - const realApi = fs.readFileSync(realApiPath, 'utf8'); + const realApi = [ + fs.readFileSync(realApiPath, 'utf8'), + fs.readFileSync(realApiHarnessPath, 'utf8'), + ].join('\n'); expect(deterministic).toContain('access(cliEntry)'); expect(deterministic).toContain("['opened', 'waiting', 'probe', 'closed']"); @@ -491,8 +498,8 @@ describe('production qualification contract', () => { ); expect(realApi).toContain('resolveRequiredDeepSeekQualificationModels'); expect(realApi).toContain("['headless', 'acp', 'pty', 'web']"); - expect(realApi).toContain('const INJECTED_FAILURES = 1'); - expect(realApi).toContain('response.writeHead(429'); + expect(realApi).toContain('injectedFailures: 1'); + expect(realApi).toContain('failureStatus: 429'); expect(realApi).toContain('expectRateLimitCooldown: true'); }); From 76af381e1609e0dafbf3e17ce51f821359f5c04b Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:41:38 +0800 Subject: [PATCH 13/59] refactor(test): unify provider admission web trajectories --- ...r-request-admission-web-trajectory.test.ts | 458 +------------- .../providerAdmissionWebTrajectoryHarness.ts | 562 ++++++++++++++++++ ...-provider-admission-web-trajectory.test.ts | 464 +-------------- 3 files changed, 566 insertions(+), 918 deletions(-) create mode 100644 packages/cli/tests/integration/real-api/providerAdmissionWebTrajectoryHarness.ts diff --git a/packages/cli/tests/integration/real-api/provider-request-admission-web-trajectory.test.ts b/packages/cli/tests/integration/real-api/provider-request-admission-web-trajectory.test.ts index 2a091b33a..c127ceab4 100644 --- a/packages/cli/tests/integration/real-api/provider-request-admission-web-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/provider-request-admission-web-trajectory.test.ts @@ -1,457 +1,3 @@ -import { type ChildProcess, spawn } from 'node:child_process'; -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { createServer as createNetServer } from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { chromium } from 'playwright'; -import { describe, expect, it } from 'vitest'; -import { - captureProcessIdentity, - processIdentityMatches, -} from '../../../src/utils/process/ProcessIdentity.js'; -import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; -import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; -import { - buildRealApiRuntimeConfig, - expandDeepSeekModelMatrix, - getEnabledModelConfigs, - isRealApiTestEnabled, -} from './testConfig.js'; +import { defineProviderAdmissionWebTrajectory } from './providerAdmissionWebTrajectoryHarness.js'; -const models = isRealApiTestEnabled() - ? expandDeepSeekModelMatrix( - getEnabledModelConfigs().filter((config) => config.id === 'deepseek') - ) - : []; -const cliEntry = path.resolve(import.meta.dirname, '../../../dist/blade.js'); - -interface EventProbe { - events: Array<{ type: string; properties: Record }>; - close(): Promise; -} - -function safeSlug(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Web Provider admission server did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Web Provider admission port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - -async function createSession(origin: string, workspace: string): Promise { - const response = await fetch(`${origin}/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - projectPath: workspace, - title: 'Provider admission qualification', - }), - }); - if (!response.ok) { - throw new Error(`Web admission Session creation failed: ${response.status}`); - } - const result = (await response.json()) as { sessionId?: unknown }; - if (typeof result.sessionId !== 'string') { - throw new Error('Web admission Session creation returned no ID'); - } - return result.sessionId; -} - -async function openEventProbe( - origin: string, - sessionId: string, - workspace: string -): Promise { - const controller = new AbortController(); - const url = new URL(`${origin}/sessions/${sessionId}/events`); - url.searchParams.set('projectPath', workspace); - const response = await fetch(url, { signal: controller.signal }); - if (!response.ok || !response.body) { - controller.abort(); - throw new Error(`Web admission SSE failed: ${response.status}`); - } - const events: EventProbe['events'] = []; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let readError: unknown; - const consume = (frame: string) => { - const data = frame - .split(/\r?\n/) - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).trimStart()) - .join('\n'); - if (!data) return; - const event = JSON.parse(data) as { type?: unknown; properties?: unknown }; - if ( - typeof event.type === 'string' && - event.properties && - typeof event.properties === 'object' && - !Array.isArray(event.properties) - ) { - events.push({ - type: event.type, - properties: event.properties as Record, - }); - } - }; - const reading = (async () => { - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - buffer += decoder.decode(chunk.value, { stream: true }); - const frames = buffer.split(/\r?\n\r?\n/); - buffer = frames.pop() ?? ''; - for (const frame of frames) consume(frame); - } - } catch (error) { - if (!controller.signal.aborted) readError = error; - } - })(); - await waitFor( - () => events.some((event) => event.type === 'connected'), - 'Web admission SSE did not connect', - 20_000 - ); - return { - events, - async close() { - controller.abort(); - await reader.cancel().catch(() => undefined); - await reading; - if (readError) throw readError; - }, - }; -} - -describe - .skipIf(!isRealApiTestEnabled()) - .sequential('bounded fair Provider admission Web control', () => { - it.each(models)( - '$model serializes two production Web Sessions', - async (model) => { - if (!model.baseURL) - throw new Error(`Missing Provider base URL for ${model.model}`); - const root = await mkdtemp( - path.join( - os.tmpdir(), - `blade-provider-admission-web-${safeSlug(model.model)}-` - ) - ); - const home = path.join(root, 'home'); - const storageRoot = path.join(root, 'storage'); - const workspace = path.join(root, 'workspace'); - const barrierPath = path.join(root, 'provider-hold-ready'); - const proxy = await startRecordingProviderProxy(model.baseURL, { - holdRequestNumber: 1, - holdMs: 10_000, - onHold: async () => { - await writeFile(barrierPath, 'ready\n'); - }, - }); - const config = buildRealApiRuntimeConfig({ - ...model, - baseURL: proxy.baseUrl, - }); - const port = await reservePort(); - let child: ChildProcess | undefined; - let probe: EventProbe | undefined; - let browser: Awaited> | undefined; - const output: string[] = []; - const browserFaults: string[] = []; - try { - await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(storageRoot, { recursive: true }), - mkdir(workspace, { recursive: true }), - ]); - await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - permissionMode: 'yolo', - providerRequestConcurrency: 1, - providerRequestAdmissionMs: 120_000, - providerCircuitBreakerOpenMs: 0, - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - null, - 2 - )}\n`, - { mode: 0o600 } - ); - child = spawn( - process.execPath, - [cliEntry, 'serve', '--hostname', '127.0.0.1', '--port', String(port)], - { - cwd: workspace, - env: { - ...process.env, - HOME: home, - BLADE_STORAGE_ROOT: storageRoot, - BLADE_AUTO_MEMORY: '0', - BLADE_TELEMETRY_DISABLED: '1', - }, - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - child.stdout?.on('data', (chunk) => output.push(chunk.toString())); - child.stderr?.on('data', (chunk) => output.push(chunk.toString())); - const childIdentity = child.pid - ? captureProcessIdentity(child.pid) - : undefined; - const origin = `http://127.0.0.1:${port}`; - await waitFor( - async () => { - try { - return (await fetch(`${origin}/health`)).ok; - } catch { - return false; - } - }, - 'Web Provider admission server did not become ready', - 20_000 - ); - const primarySessionId = await createSession(origin, workspace); - const primaryMarker = `WEB_ADMISSION_PRIMARY_${Date.now()}`; - const secondaryMarker = `WEB_ADMISSION_SECONDARY_${Date.now()}`; - const primarySubmission = await fetch( - `${origin}/sessions/${primarySessionId}/message`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - content: `Reply with exactly ${primaryMarker} and no other text.`, - permissionMode: 'yolo', - }), - } - ); - if (!primarySubmission.ok) { - throw new Error( - `Primary Web admission submission failed: ${primarySubmission.status}` - ); - } - await waitFor(async () => { - try { - await access(barrierPath); - return true; - } catch { - return false; - } - }, 'Primary Web request did not reach Provider hold barrier'); - - const secondarySessionId = await createSession(origin, workspace); - probe = await openEventProbe(origin, secondarySessionId, workspace); - browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(); - page.on('pageerror', (error) => - browserFaults.push(`pageerror:${error.message}`) - ); - page.on('console', (message) => { - if (message.type() === 'error') { - browserFaults.push(`console:${message.text()}`); - } - }); - const navigation = new URL(origin); - navigation.searchParams.set('session', secondarySessionId); - navigation.searchParams.set('project', workspace); - await page.goto(navigation.href, { waitUntil: 'domcontentloaded' }); - const composer = page.locator('textarea[data-blade-composer]'); - await composer.waitFor({ state: 'visible', timeout: 30_000 }); - const permissionMode = page.locator('[data-blade-permission-mode]'); - if ( - (await permissionMode.getAttribute('data-blade-permission-mode')) !== 'yolo' - ) { - await permissionMode.click(); - await page.locator('[data-blade-permission-option="yolo"]').click(); - await page.locator('[data-blade-yolo-confirm]').click(); - } - await composer.fill( - `Reply with exactly ${secondaryMarker} and no other text.` - ); - const secondarySubmission = page.waitForResponse( - (response) => - response.request().method() === 'POST' && - response.url().includes(`/sessions/${secondarySessionId}/message`) - ); - await composer.press('Enter'); - if (!(await secondarySubmission).ok()) { - throw new Error('Secondary Web admission GUI submission failed'); - } - await page.getByText('Capacity queue', { exact: false }).waitFor({ - state: 'visible', - timeout: 30_000, - }); - await waitFor( - () => - probe?.events.some( - (event) => - event.type === 'provider.admission' && - event.properties.phase === 'queued' && - event.properties.inFlight === 1 && - event.properties.limit === 1 - ) === true, - 'Secondary Web SSE did not project Provider admission queue' - ); - await page.getByText(secondaryMarker, { exact: true }).waitFor({ - state: 'visible', - timeout: 120_000, - }); - await waitFor( - () => - probe?.events.some( - (event) => - event.type === 'provider.admission' && - event.properties.phase === 'admitted' - ) === true, - 'Secondary Web SSE did not project Provider admission' - ); - let primaryTranscript = ''; - await waitFor( - async () => { - try { - primaryTranscript = await readFile( - findSessionTranscript(storageRoot, primarySessionId), - 'utf8' - ); - return primaryTranscript.includes(primaryMarker); - } catch { - return false; - } - }, - 'Primary Web Session did not finish independently', - 120_000 - ); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page - .locator('textarea[data-blade-composer]') - .waitFor({ state: 'visible', timeout: 30_000 }); - await page.getByText(secondaryMarker, { exact: true }).waitFor({ - state: 'visible', - timeout: 30_000, - }); - if ((await page.locator('body').textContent())?.includes('Capacity queue')) { - throw new Error('Web reload restored transient Provider admission'); - } - const secondaryTranscript = await readFile( - findSessionTranscript(storageRoot, secondarySessionId), - 'utf8' - ); - expect(`${primaryTranscript}${secondaryTranscript}`).not.toContain( - 'provider_admission' - ); - expect(proxy.requestBodies).toHaveLength(2); - expect(proxy.maxInFlight).toBe(1); - expect(proxy.heldRequestNumbers).toEqual([1]); - expect( - (proxy.requestFinishedAt[0] ?? 0) - (proxy.requestStartedAt[0] ?? 0) - ).toBeGreaterThanOrEqual(9_500); - expect(proxy.requestStartedAt[1]).toBeGreaterThanOrEqual( - proxy.requestFinishedAt[0] ?? Number.MAX_SAFE_INTEGER - ); - expect(browserFaults).toEqual([]); - expect( - `${output.join('')}${await page.content()}${JSON.stringify(probe.events)}` - ).not.toContain(model.apiKey); - - await probe.close(); - probe = undefined; - await browser.close(); - browser = undefined; - child.kill('SIGTERM'); - const exit = await waitForChildExit(child); - if (exit.signal || exit.code !== 0) { - throw new Error( - `Web Provider admission exited ${ - exit.code ?? exit.signal - }: ${output.join('').replaceAll(model.apiKey, '[redacted]')}` - ); - } - if (child.pid && childIdentity) { - expect(processIdentityMatches(child.pid, childIdentity)).toBe(false); - } - } finally { - await probe?.close().catch(() => undefined); - await browser?.close().catch(() => undefined); - if (child && child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); - await waitForChildExit(child, 10_000).catch(() => undefined); - } - await proxy.close(); - await rm(root, { recursive: true, force: true }); - } - }, - 240_000 - ); - }); +defineProviderAdmissionWebTrajectory('queued'); diff --git a/packages/cli/tests/integration/real-api/providerAdmissionWebTrajectoryHarness.ts b/packages/cli/tests/integration/real-api/providerAdmissionWebTrajectoryHarness.ts new file mode 100644 index 000000000..974dfb490 --- /dev/null +++ b/packages/cli/tests/integration/real-api/providerAdmissionWebTrajectoryHarness.ts @@ -0,0 +1,562 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer as createNetServer } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { chromium } from 'playwright'; +import { describe, expect, it } from 'vitest'; +import { + captureProcessIdentity, + processIdentityMatches, +} from '../../../src/utils/process/ProcessIdentity.js'; +import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; +import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; +import { + buildRealApiRuntimeConfig, + expandDeepSeekModelMatrix, + getEnabledModelConfigs, + isRealApiTestEnabled, +} from './testConfig.js'; + +const models = isRealApiTestEnabled() + ? expandDeepSeekModelMatrix( + getEnabledModelConfigs().filter((config) => config.id === 'deepseek') + ) + : []; +const cliEntry = path.resolve(import.meta.dirname, '../../../dist/blade.js'); + +const scenarios = { + queued: { + suiteTitle: 'bounded fair Provider admission Web control', + testTitle: '$model serializes two production Web Sessions', + rootPrefix: 'blade-provider-admission-web', + sessionTitle: 'Provider admission qualification', + primaryMarkerPrefix: 'WEB_ADMISSION_PRIMARY', + secondaryMarkerPrefix: 'WEB_ADMISSION_SECONDARY', + workspacePaddingBytes: 0, + pendingBytes: undefined, + }, + weighted: { + suiteTitle: 'bounded weighted Provider admission Web control', + testTitle: '$model rejects a second production Web Session before Provider traffic', + rootPrefix: 'blade-weighted-admission-web', + sessionTitle: 'Weighted Provider admission qualification', + primaryMarkerPrefix: 'WEIGHTED_WEB_PRIMARY', + secondaryMarkerPrefix: 'WEIGHTED_WEB_SECONDARY', + workspacePaddingBytes: 30 * 1024, + pendingBytes: 64 * 1024, + }, +} as const; + +export type ProviderAdmissionScenario = keyof typeof scenarios; + +interface EventProbe { + events: Array<{ type: string; properties: Record }>; + close(): Promise; +} + +function safeSlug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +async function waitFor( + predicate: () => boolean | Promise, + message: string, + timeoutMs = 60_000 +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + if (await predicate()) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(message, { cause: lastError }); +} + +function waitForChildExit( + child: ChildProcess, + timeoutMs = 30_000 +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error('Web Provider admission server did not exit')); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + child.off('error', onError); + child.off('exit', onExit); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + resolve({ code, signal }); + }; + child.once('error', onError); + child.once('exit', onExit); + }); +} + +async function reservePort(): Promise { + const server = createNetServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Unable to reserve Web Provider admission port'); + } + const port = address.port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +async function createSession( + origin: string, + workspace: string, + title: string +): Promise { + const response = await fetch(`${origin}/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + projectPath: workspace, + title, + }), + }); + if (!response.ok) { + throw new Error(`Web admission Session creation failed: ${response.status}`); + } + const result = (await response.json()) as { sessionId?: unknown }; + if (typeof result.sessionId !== 'string') { + throw new Error('Web admission Session creation returned no ID'); + } + return result.sessionId; +} + +async function openEventProbe( + origin: string, + sessionId: string, + workspace: string +): Promise { + const controller = new AbortController(); + const url = new URL(`${origin}/sessions/${sessionId}/events`); + url.searchParams.set('projectPath', workspace); + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok || !response.body) { + controller.abort(); + throw new Error(`Web admission SSE failed: ${response.status}`); + } + const events: EventProbe['events'] = []; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let readError: unknown; + const consume = (frame: string) => { + const data = frame + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n'); + if (!data) return; + const event = JSON.parse(data) as { type?: unknown; properties?: unknown }; + if ( + typeof event.type === 'string' && + event.properties && + typeof event.properties === 'object' && + !Array.isArray(event.properties) + ) { + events.push({ + type: event.type, + properties: event.properties as Record, + }); + } + }; + const reading = (async () => { + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }); + const frames = buffer.split(/\r?\n\r?\n/); + buffer = frames.pop() ?? ''; + for (const frame of frames) consume(frame); + } + } catch (error) { + if (!controller.signal.aborted) readError = error; + } + })(); + await waitFor( + () => events.some((event) => event.type === 'connected'), + 'Web admission SSE did not connect', + 20_000 + ); + return { + events, + async close() { + controller.abort(); + await reader.cancel().catch(() => undefined); + await reading; + if (readError) throw readError; + }, + }; +} + +export function defineProviderAdmissionWebTrajectory( + scenarioName: ProviderAdmissionScenario +): void { + const scenario = scenarios[scenarioName]; + const weighted = scenarioName === 'weighted'; + + describe.skipIf(!isRealApiTestEnabled()).sequential(scenario.suiteTitle, () => { + it.each(models)( + scenario.testTitle, + async (model) => { + if (!model.baseURL) + throw new Error(`Missing Provider base URL for ${model.model}`); + const root = await mkdtemp( + path.join(os.tmpdir(), `${scenario.rootPrefix}-${safeSlug(model.model)}-`) + ); + const home = path.join(root, 'home'); + const storageRoot = path.join(root, 'storage'); + const workspace = path.join(root, 'workspace'); + const barrierPath = path.join(root, 'provider-hold-ready'); + const proxy = await startRecordingProviderProxy(model.baseURL, { + holdRequestNumber: 1, + holdMs: 10_000, + onHold: async () => { + await writeFile(barrierPath, 'ready\n'); + }, + }); + const config = buildRealApiRuntimeConfig({ + ...model, + baseURL: proxy.baseUrl, + }); + const port = await reservePort(); + let child: ChildProcess | undefined; + let probe: EventProbe | undefined; + let browser: Awaited> | undefined; + const output: string[] = []; + const browserFaults: string[] = []; + try { + await Promise.all([ + mkdir(path.join(home, '.blade'), { recursive: true }), + mkdir(storageRoot, { recursive: true }), + mkdir(workspace, { recursive: true }), + ]); + if (scenario.workspacePaddingBytes > 0) { + await writeFile( + path.join(workspace, 'AGENTS.md'), + `# Weighted admission fixture\n\n\nFollow the current user request exactly.\n` + ); + } + await writeFile( + path.join(home, '.blade', 'config.json'), + `${JSON.stringify( + { + currentModelId: config.currentModelId, + models: config.models, + modelProviders: config.modelProviders, + permissionMode: 'yolo', + providerRequestConcurrency: 1, + providerRequestAdmissionMs: 120_000, + ...(scenario.pendingBytes + ? { providerRequestPendingBytes: scenario.pendingBytes } + : {}), + providerCircuitBreakerOpenMs: 0, + hooks: { enabled: false }, + disableAllHooks: true, + mcpServers: {}, + }, + null, + 2 + )}\n`, + { mode: 0o600 } + ); + child = spawn( + process.execPath, + [cliEntry, 'serve', '--hostname', '127.0.0.1', '--port', String(port)], + { + cwd: workspace, + env: { + ...process.env, + HOME: home, + BLADE_STORAGE_ROOT: storageRoot, + BLADE_AUTO_MEMORY: '0', + BLADE_TELEMETRY_DISABLED: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + child.stdout?.on('data', (chunk) => output.push(chunk.toString())); + child.stderr?.on('data', (chunk) => output.push(chunk.toString())); + const childIdentity = child.pid + ? captureProcessIdentity(child.pid) + : undefined; + const origin = `http://127.0.0.1:${port}`; + await waitFor( + async () => { + try { + return (await fetch(`${origin}/health`)).ok; + } catch { + return false; + } + }, + 'Web Provider admission server did not become ready', + 20_000 + ); + const primarySessionId = await createSession( + origin, + workspace, + scenario.sessionTitle + ); + const primaryMarker = `${scenario.primaryMarkerPrefix}_${Date.now()}`; + const secondaryMarker = `${scenario.secondaryMarkerPrefix}_${Date.now()}`; + const primarySubmission = await fetch( + `${origin}/sessions/${primarySessionId}/message`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + content: `Reply with exactly ${primaryMarker} and no other text.`, + permissionMode: 'yolo', + }), + } + ); + if (!primarySubmission.ok) { + throw new Error( + `Primary Web admission submission failed: ${primarySubmission.status}` + ); + } + await waitFor(async () => { + try { + await access(barrierPath); + return true; + } catch { + return false; + } + }, 'Primary Web request did not reach Provider hold barrier'); + + const secondarySessionId = await createSession( + origin, + workspace, + scenario.sessionTitle + ); + probe = await openEventProbe(origin, secondarySessionId, workspace); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + page.on('pageerror', (error) => + browserFaults.push(`pageerror:${error.message}`) + ); + page.on('console', (message) => { + if (message.type() === 'error') { + browserFaults.push(`console:${message.text()}`); + } + }); + const navigation = new URL(origin); + navigation.searchParams.set('session', secondarySessionId); + navigation.searchParams.set('project', workspace); + await page.goto(navigation.href, { waitUntil: 'domcontentloaded' }); + const composer = page.locator('textarea[data-blade-composer]'); + await composer.waitFor({ state: 'visible', timeout: 30_000 }); + const permissionMode = page.locator('[data-blade-permission-mode]'); + if ( + (await permissionMode.getAttribute('data-blade-permission-mode')) !== 'yolo' + ) { + await permissionMode.click(); + await page.locator('[data-blade-permission-option="yolo"]').click(); + await page.locator('[data-blade-yolo-confirm]').click(); + } + await composer.fill( + weighted + ? `This request must be rejected before it reaches the Provider. ${secondaryMarker}` + : `Reply with exactly ${secondaryMarker} and no other text.` + ); + const secondarySubmission = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes(`/sessions/${secondarySessionId}/message`) + ); + await composer.press('Enter'); + if (!(await secondarySubmission).ok()) { + throw new Error('Secondary Web admission GUI submission failed'); + } + let primaryTranscript = ''; + if (weighted) { + await waitFor( + () => + probe?.events.some( + (event) => + event.type === 'provider.admission' && + event.properties.phase === 'rejected' && + event.properties.resource === 'pending_bytes' && + event.properties.reason === 'queue_full' + ) === true, + 'Weighted Web SSE did not project pending-byte rejection' + ); + expect( + proxy.requestBodies.every((body) => !body.includes(secondaryMarker)) + ).toBe(true); + await waitFor( + () => proxy.requestFinishedAt.length === 1, + 'Weighted primary Web Provider stream did not finish', + 120_000 + ); + await waitFor( + async () => { + try { + primaryTranscript = await readFile( + findSessionTranscript(storageRoot, primarySessionId), + 'utf8' + ); + return primaryTranscript + .split('\n') + .some((line) => line.includes('"role":"assistant"')); + } catch { + return false; + } + }, + 'Weighted primary Web Session did not commit an assistant response', + 30_000 + ); + await waitFor( + async () => !(await composer.isDisabled()), + 'Weighted secondary Web composer did not recover after rejection', + 30_000 + ); + } else { + await page.getByText('Capacity queue', { exact: false }).waitFor({ + state: 'visible', + timeout: 30_000, + }); + await waitFor( + () => + probe?.events.some( + (event) => + event.type === 'provider.admission' && + event.properties.phase === 'queued' && + event.properties.inFlight === 1 && + event.properties.limit === 1 + ) === true, + 'Secondary Web SSE did not project Provider admission queue' + ); + await page.getByText(secondaryMarker, { exact: true }).waitFor({ + state: 'visible', + timeout: 120_000, + }); + await waitFor( + () => + probe?.events.some( + (event) => + event.type === 'provider.admission' && + event.properties.phase === 'admitted' + ) === true, + 'Secondary Web SSE did not project Provider admission' + ); + await waitFor( + async () => { + try { + primaryTranscript = await readFile( + findSessionTranscript(storageRoot, primarySessionId), + 'utf8' + ); + return primaryTranscript.includes(primaryMarker); + } catch { + return false; + } + }, + 'Primary Web Session did not finish independently', + 120_000 + ); + } + await page.reload({ waitUntil: 'domcontentloaded' }); + await page + .locator('textarea[data-blade-composer]') + .waitFor({ state: 'visible', timeout: 30_000 }); + if (!weighted) { + await page.getByText(secondaryMarker, { exact: true }).waitFor({ + state: 'visible', + timeout: 30_000, + }); + } + if ((await page.locator('body').textContent())?.includes('Capacity queue')) { + throw new Error('Web reload restored transient Provider admission'); + } + const secondaryTranscript = await readFile( + findSessionTranscript(storageRoot, secondarySessionId), + 'utf8' + ); + expect(`${primaryTranscript}${secondaryTranscript}`).not.toContain( + 'provider_admission' + ); + if (weighted) { + expect(proxy.requestBodies.length).toBeGreaterThanOrEqual(1); + expect( + proxy.requestBodies.every((body) => !body.includes(secondaryMarker)) + ).toBe(true); + } else { + expect(proxy.requestBodies).toHaveLength(2); + expect( + (proxy.requestFinishedAt[0] ?? 0) - (proxy.requestStartedAt[0] ?? 0) + ).toBeGreaterThanOrEqual(9_500); + expect(proxy.requestStartedAt[1]).toBeGreaterThanOrEqual( + proxy.requestFinishedAt[0] ?? Number.MAX_SAFE_INTEGER + ); + } + expect(proxy.maxInFlight).toBe(1); + expect(proxy.heldRequestNumbers).toEqual([1]); + expect(browserFaults).toEqual([]); + expect( + `${output.join('')}${await page.content()}${JSON.stringify(probe.events)}` + ).not.toContain(model.apiKey); + + await probe.close(); + probe = undefined; + await browser.close(); + browser = undefined; + child.kill('SIGTERM'); + const exit = await waitForChildExit(child); + if (exit.signal || exit.code !== 0) { + throw new Error( + `Web Provider admission exited ${ + exit.code ?? exit.signal + }: ${output.join('').replaceAll(model.apiKey, '[redacted]')}` + ); + } + if (child.pid && childIdentity) { + expect(processIdentityMatches(child.pid, childIdentity)).toBe(false); + } + } finally { + await probe?.close().catch(() => undefined); + await browser?.close().catch(() => undefined); + if (child && child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await waitForChildExit(child, 10_000).catch(() => undefined); + } + await proxy.close(); + await rm(root, { recursive: true, force: true }); + } + }, + 240_000 + ); + }); +} diff --git a/packages/cli/tests/integration/real-api/weighted-provider-admission-web-trajectory.test.ts b/packages/cli/tests/integration/real-api/weighted-provider-admission-web-trajectory.test.ts index 432fd6a28..bec6273ce 100644 --- a/packages/cli/tests/integration/real-api/weighted-provider-admission-web-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/weighted-provider-admission-web-trajectory.test.ts @@ -1,463 +1,3 @@ -import { type ChildProcess, spawn } from 'node:child_process'; -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { createServer as createNetServer } from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { chromium } from 'playwright'; -import { describe, expect, it } from 'vitest'; -import { - captureProcessIdentity, - processIdentityMatches, -} from '../../../src/utils/process/ProcessIdentity.js'; -import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; -import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; -import { - buildRealApiRuntimeConfig, - expandDeepSeekModelMatrix, - getEnabledModelConfigs, - isRealApiTestEnabled, -} from './testConfig.js'; +import { defineProviderAdmissionWebTrajectory } from './providerAdmissionWebTrajectoryHarness.js'; -const models = isRealApiTestEnabled() - ? expandDeepSeekModelMatrix( - getEnabledModelConfigs().filter((config) => config.id === 'deepseek') - ) - : []; -const cliEntry = path.resolve(import.meta.dirname, '../../../dist/blade.js'); - -interface EventProbe { - events: Array<{ type: string; properties: Record }>; - close(): Promise; -} - -function safeSlug(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Weighted Web admission server did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve weighted Web admission port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - -async function createSession(origin: string, workspace: string): Promise { - const response = await fetch(`${origin}/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - projectPath: workspace, - title: 'Weighted Provider admission qualification', - }), - }); - if (!response.ok) { - throw new Error(`Weighted Web Session creation failed: ${response.status}`); - } - const result = (await response.json()) as { sessionId?: unknown }; - if (typeof result.sessionId !== 'string') { - throw new Error('Weighted Web Session creation returned no ID'); - } - return result.sessionId; -} - -async function openEventProbe( - origin: string, - sessionId: string, - workspace: string -): Promise { - const controller = new AbortController(); - const url = new URL(`${origin}/sessions/${sessionId}/events`); - url.searchParams.set('projectPath', workspace); - const response = await fetch(url, { signal: controller.signal }); - if (!response.ok || !response.body) { - controller.abort(); - throw new Error(`Weighted Web SSE failed: ${response.status}`); - } - const events: EventProbe['events'] = []; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let readError: unknown; - const consume = (frame: string) => { - const data = frame - .split(/\r?\n/) - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).trimStart()) - .join('\n'); - if (!data) return; - const event = JSON.parse(data) as { type?: unknown; properties?: unknown }; - if ( - typeof event.type === 'string' && - event.properties && - typeof event.properties === 'object' && - !Array.isArray(event.properties) - ) { - events.push({ - type: event.type, - properties: event.properties as Record, - }); - } - }; - const reading = (async () => { - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - buffer += decoder.decode(chunk.value, { stream: true }); - const frames = buffer.split(/\r?\n\r?\n/); - buffer = frames.pop() ?? ''; - for (const frame of frames) consume(frame); - } - } catch (error) { - if (!controller.signal.aborted) readError = error; - } - })(); - await waitFor( - () => events.some((event) => event.type === 'connected'), - 'Weighted Web SSE did not connect', - 20_000 - ); - return { - events, - async close() { - controller.abort(); - await reader.cancel().catch(() => undefined); - await reading; - if (readError) throw readError; - }, - }; -} - -describe - .skipIf(!isRealApiTestEnabled()) - .sequential('bounded weighted Provider admission Web control', () => { - it.each(models)( - '$model rejects a second production Web Session before Provider traffic', - async (model) => { - if (!model.baseURL) - throw new Error(`Missing Provider base URL for ${model.model}`); - const root = await mkdtemp( - path.join( - os.tmpdir(), - `blade-weighted-admission-web-${safeSlug(model.model)}-` - ) - ); - const home = path.join(root, 'home'); - const storageRoot = path.join(root, 'storage'); - const workspace = path.join(root, 'workspace'); - const barrierPath = path.join(root, 'provider-hold-ready'); - const proxy = await startRecordingProviderProxy(model.baseURL, { - holdRequestNumber: 1, - holdMs: 10_000, - onHold: async () => { - await writeFile(barrierPath, 'ready\n'); - }, - }); - const config = buildRealApiRuntimeConfig({ - ...model, - baseURL: proxy.baseUrl, - }); - const port = await reservePort(); - let child: ChildProcess | undefined; - let probe: EventProbe | undefined; - let browser: Awaited> | undefined; - const output: string[] = []; - const browserFaults: string[] = []; - try { - await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(storageRoot, { recursive: true }), - mkdir(workspace, { recursive: true }), - ]); - await writeFile( - path.join(workspace, 'AGENTS.md'), - `# Weighted admission fixture\n\n\nFollow the current user request exactly.\n` - ); - await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - permissionMode: 'yolo', - providerRequestConcurrency: 1, - providerRequestAdmissionMs: 120_000, - providerRequestPendingBytes: 64 * 1024, - providerCircuitBreakerOpenMs: 0, - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - null, - 2 - )}\n`, - { mode: 0o600 } - ); - child = spawn( - process.execPath, - [cliEntry, 'serve', '--hostname', '127.0.0.1', '--port', String(port)], - { - cwd: workspace, - env: { - ...process.env, - HOME: home, - BLADE_STORAGE_ROOT: storageRoot, - BLADE_AUTO_MEMORY: '0', - BLADE_TELEMETRY_DISABLED: '1', - }, - stdio: ['ignore', 'pipe', 'pipe'], - } - ); - child.stdout?.on('data', (chunk) => output.push(chunk.toString())); - child.stderr?.on('data', (chunk) => output.push(chunk.toString())); - const childIdentity = child.pid - ? captureProcessIdentity(child.pid) - : undefined; - const origin = `http://127.0.0.1:${port}`; - await waitFor( - async () => { - try { - return (await fetch(`${origin}/health`)).ok; - } catch { - return false; - } - }, - 'Weighted Web admission server did not become ready', - 20_000 - ); - const primarySessionId = await createSession(origin, workspace); - const primaryMarker = `WEIGHTED_WEB_PRIMARY_${Date.now()}`; - const secondaryMarker = `WEIGHTED_WEB_SECONDARY_${Date.now()}`; - const primarySubmission = await fetch( - `${origin}/sessions/${primarySessionId}/message`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - content: `Reply with exactly ${primaryMarker} and no other text.`, - permissionMode: 'yolo', - }), - } - ); - if (!primarySubmission.ok) { - throw new Error( - `Weighted primary Web submission failed: ${primarySubmission.status}` - ); - } - await waitFor(async () => { - try { - await access(barrierPath); - return true; - } catch { - return false; - } - }, 'Weighted primary Web request did not reach Provider hold barrier'); - - const secondarySessionId = await createSession(origin, workspace); - probe = await openEventProbe(origin, secondarySessionId, workspace); - browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(); - page.on('pageerror', (error) => - browserFaults.push(`pageerror:${error.message}`) - ); - page.on('console', (message) => { - if (message.type() === 'error') { - browserFaults.push(`console:${message.text()}`); - } - }); - const navigation = new URL(origin); - navigation.searchParams.set('session', secondarySessionId); - navigation.searchParams.set('project', workspace); - await page.goto(navigation.href, { waitUntil: 'domcontentloaded' }); - const composer = page.locator('textarea[data-blade-composer]'); - await composer.waitFor({ state: 'visible', timeout: 30_000 }); - const permissionMode = page.locator('[data-blade-permission-mode]'); - if ( - (await permissionMode.getAttribute('data-blade-permission-mode')) !== 'yolo' - ) { - await permissionMode.click(); - await page.locator('[data-blade-permission-option="yolo"]').click(); - await page.locator('[data-blade-yolo-confirm]').click(); - } - await composer.fill( - `This request must be rejected before it reaches the Provider. ${ - secondaryMarker - }` - ); - const secondarySubmission = page.waitForResponse( - (response) => - response.request().method() === 'POST' && - response.url().includes(`/sessions/${secondarySessionId}/message`) - ); - await composer.press('Enter'); - if (!(await secondarySubmission).ok()) { - throw new Error('Weighted secondary Web GUI submission failed'); - } - await waitFor( - () => - probe?.events.some( - (event) => - event.type === 'provider.admission' && - event.properties.phase === 'rejected' && - event.properties.resource === 'pending_bytes' && - event.properties.reason === 'queue_full' - ) === true, - 'Weighted Web SSE did not project pending-byte rejection' - ); - expect( - proxy.requestBodies.every((body) => !body.includes(secondaryMarker)) - ).toBe(true); - - await waitFor( - () => proxy.requestFinishedAt.length === 1, - 'Weighted primary Web Provider stream did not finish', - 120_000 - ); - let primaryTranscript = ''; - await waitFor( - async () => { - try { - primaryTranscript = await readFile( - findSessionTranscript(storageRoot, primarySessionId), - 'utf8' - ); - return primaryTranscript - .split('\n') - .some((line) => line.includes('"role":"assistant"')); - } catch { - return false; - } - }, - 'Weighted primary Web Session did not commit an assistant response', - 30_000 - ); - await waitFor( - async () => !(await composer.isDisabled()), - 'Weighted secondary Web composer did not recover after rejection', - 30_000 - ); - await page.reload({ waitUntil: 'domcontentloaded' }); - await page - .locator('textarea[data-blade-composer]') - .waitFor({ state: 'visible', timeout: 30_000 }); - if ((await page.locator('body').textContent())?.includes('Capacity queue')) { - throw new Error( - 'Weighted Web reload restored transient Provider admission' - ); - } - const secondaryTranscript = await readFile( - findSessionTranscript(storageRoot, secondarySessionId), - 'utf8' - ); - expect(`${primaryTranscript}${secondaryTranscript}`).not.toContain( - 'provider_admission' - ); - expect(proxy.requestBodies.length).toBeGreaterThanOrEqual(1); - expect( - proxy.requestBodies.every((body) => !body.includes(secondaryMarker)) - ).toBe(true); - expect(proxy.maxInFlight).toBe(1); - expect(proxy.heldRequestNumbers).toEqual([1]); - expect( - (proxy.requestFinishedAt[0] ?? 0) - (proxy.requestStartedAt[0] ?? 0) - ).toBeGreaterThanOrEqual(9_500); - expect(browserFaults).toEqual([]); - expect( - `${output.join('')}${await page.content()}${JSON.stringify(probe.events)}` - ).not.toContain(model.apiKey); - - await probe.close(); - probe = undefined; - await browser.close(); - browser = undefined; - child.kill('SIGTERM'); - const exit = await waitForChildExit(child); - if (exit.signal || exit.code !== 0) { - throw new Error( - `Weighted Web admission exited ${ - exit.code ?? exit.signal - }: ${output.join('').replaceAll(model.apiKey, '[redacted]')}` - ); - } - if (child.pid && childIdentity) { - expect(processIdentityMatches(child.pid, childIdentity)).toBe(false); - } - } finally { - await probe?.close().catch(() => undefined); - await browser?.close().catch(() => undefined); - if (child && child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); - await waitForChildExit(child, 10_000).catch(() => undefined); - } - await proxy.close(); - await rm(root, { recursive: true, force: true }); - } - }, - 240_000 - ); - }); +defineProviderAdmissionWebTrajectory('weighted'); From 0f2e0f5bc54c89d5529d6de59980681bb5828c6d Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:45:56 +0800 Subject: [PATCH 14/59] refactor(test): share async process utilities --- .../compaction-memory-consolidation.test.ts | 30 ++------ .../goal-execution-host-failure.test.ts | 31 ++------ .../integration/goal-turn-lineage.test.ts | 15 +--- .../provider-rate-limit-cooldown.test.ts | 70 ++---------------- .../browser-preview-trajectory.test.ts | 51 ++----------- ...on-memory-consolidation-trajectory.test.ts | 16 +---- ...eground-command-handoff-trajectory.test.ts | 52 ++------------ ...-execution-host-failure-trajectory.test.ts | 31 ++------ .../goal-turn-lineage-trajectory.test.ts | 31 ++------ .../graceful-shutdown-trajectory.test.ts | 52 ++------------ .../prompt-cache-surface-trajectory.test.ts | 51 ++----------- ...er-attempt-deadline-web-trajectory.test.ts | 51 ++----------- .../providerAdmissionWebTrajectoryHarness.ts | 71 ++----------------- .../providerRecoveryTrajectoryHarness.ts | 52 ++------------ ...n-runtime-residency-acp-trajectory.test.ts | 14 +--- ...n-runtime-residency-web-trajectory.test.ts | 71 ++----------------- .../tool-admission-trajectory.test.ts | 19 +---- .../tui-task-attention-trajectory.test.ts | 18 +---- .../turn-activity-surface-trajectory.test.ts | 51 ++----------- ...hted-task-admission-acp-trajectory.test.ts | 14 +--- ...hted-task-admission-web-trajectory.test.ts | 71 ++----------------- .../turn-activity-surfaces.test.ts | 14 +--- packages/cli/tests/support/asyncTestUtils.ts | 67 +++++++++++++++++ .../backgroundSubagentCompletionWebDriver.ts | 18 +---- .../cli/tests/support/browserToolWebDriver.ts | 19 +---- .../support/durableTaskUnreadWebDriver.ts | 18 +---- .../tests/support/followUpQueueWebDriver.ts | 18 +---- .../foregroundBoundedOutputWebDriver.ts | 19 +---- .../foregroundCommandHandoffAcpRunner.ts | 31 +------- .../foregroundProviderRecoveryAcpRunner.ts | 44 +----------- .../goalExecutionHostFailureAcpRunner.ts | 14 +--- .../goalExecutionHostFailurePtyRunner.ts | 14 +--- .../support/goalFinalizationWebDriver.ts | 18 +---- .../support/gracefulShutdownAcpRunner.ts | 31 +------- .../support/memoryConsolidationAcpRunner.ts | 31 +------- .../providerRequestAdmissionAcpRunner.ts | 44 +----------- .../support/rootTurnAutoResumeWebDriver.ts | 18 +---- .../sessionRuntimeResidencyAcpRunner.ts | 44 +----------- .../subagentResultAdoptionWebDriver.ts | 18 +---- .../taskOwnerIdentitySurfaceQualification.ts | 14 +--- .../support/tokenBudgetHandoffWebDriver.ts | 19 +---- .../tests/support/toolAdmissionAcpRunner.ts | 31 +------- .../tests/support/turnActivityAcpRunner.ts | 31 +------- .../weightedProviderAdmissionAcpRunner.ts | 44 +----------- .../support/weightedTaskAdmissionAcpRunner.ts | 44 +----------- 45 files changed, 162 insertions(+), 1363 deletions(-) create mode 100644 packages/cli/tests/support/asyncTestUtils.ts diff --git a/packages/cli/tests/integration/compaction-memory-consolidation.test.ts b/packages/cli/tests/integration/compaction-memory-consolidation.test.ts index 4ccfbbeb1..7f40b4f22 100644 --- a/packages/cli/tests/integration/compaction-memory-consolidation.test.ts +++ b/packages/cli/tests/integration/compaction-memory-consolidation.test.ts @@ -23,6 +23,10 @@ import { getProjectStoragePath } from '../../src/context/storage/pathUtils.js'; import { resetProjectionDbCache } from '../../src/context/storage/sqlite/projection.js'; import { INTERNAL_CONTROL_MESSAGE_METADATA } from '../../src/services/clientMessageVisibility.js'; import { SessionService } from '../../src/services/SessionService.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, +} from '../support/asyncTestUtils.js'; import { removeTestDirectory } from '../support/helpers/removeTestDirectory.js'; vi.unmock('node:child_process'); @@ -646,32 +650,6 @@ async function runPty(test: Fixture): Promise { }); } -async function reservePort(): Promise { - const server = createHttpServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address() as AddressInfo; - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} - -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function openEventProbe( origin: string, sessionId: string, diff --git a/packages/cli/tests/integration/goal-execution-host-failure.test.ts b/packages/cli/tests/integration/goal-execution-host-failure.test.ts index 84bcaaf6e..6eaa33074 100644 --- a/packages/cli/tests/integration/goal-execution-host-failure.test.ts +++ b/packages/cli/tests/integration/goal-execution-host-failure.test.ts @@ -7,6 +7,10 @@ import { chromium } from 'playwright'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { resetProjectionDbCache } from '../../src/context/storage/sqlite/projection.js'; import { GoalStore } from '../../src/goals/GoalStore.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, +} from '../support/asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -120,33 +124,6 @@ async function assertBlocked(test: GoalExecutionHostFailureFixture): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('No Web port'); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} - -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function waitForHttp(origin: string): Promise { await waitFor(async () => { try { diff --git a/packages/cli/tests/integration/goal-turn-lineage.test.ts b/packages/cli/tests/integration/goal-turn-lineage.test.ts index c3937c2c9..5434b7fdc 100644 --- a/packages/cli/tests/integration/goal-turn-lineage.test.ts +++ b/packages/cli/tests/integration/goal-turn-lineage.test.ts @@ -9,6 +9,7 @@ import { PersistentStore } from '../../src/context/storage/PersistentStore.js'; import { resetProjectionDbCache } from '../../src/context/storage/sqlite/projection.js'; import { GoalStore } from '../../src/goals/GoalStore.js'; import type { GoalTurnLineage } from '../../src/goals/types.js'; +import { reserveLoopbackPort as reservePort } from '../support/asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -164,20 +165,6 @@ async function assertDurableLineage( } } -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('No Web port'); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} - async function waitForHttp(origin: string): Promise { const deadline = Date.now() + 20_000; while (Date.now() < deadline) { diff --git a/packages/cli/tests/integration/provider-rate-limit-cooldown.test.ts b/packages/cli/tests/integration/provider-rate-limit-cooldown.test.ts index e74999d6e..23f866826 100644 --- a/packages/cli/tests/integration/provider-rate-limit-cooldown.test.ts +++ b/packages/cli/tests/integration/provider-rate-limit-cooldown.test.ts @@ -18,6 +18,11 @@ import { promisify } from 'node:util'; import { type Browser, chromium, type Page } from 'playwright'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { SessionSchema } from '../../src/api/schemas.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, + waitForChildExit, +} from '../support/asyncTestUtils.js'; import { isCompleteRawPtyMarkerEvidence } from '../support/foregroundBoundedOutputPtyDriver.js'; vi.unmock('node:child_process'); @@ -93,54 +98,6 @@ afterEach(async () => { ); }); -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Rate-limit qualification child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - async function readRequestBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of request) { @@ -558,23 +515,6 @@ async function runRunner(input: { return evidence; } -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve rate-limit Web port'); - } - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} - async function waitForHttp(origin: string): Promise { await waitFor( async () => { diff --git a/packages/cli/tests/integration/real-api/browser-preview-trajectory.test.ts b/packages/cli/tests/integration/real-api/browser-preview-trajectory.test.ts index 74f1d66ba..2daa17c67 100644 --- a/packages/cli/tests/integration/real-api/browser-preview-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/browser-preview-trajectory.test.ts @@ -8,6 +8,10 @@ import path from 'node:path'; import { promisify } from 'node:util'; import { chromium } from 'playwright'; import { describe, expect, it } from 'vitest'; +import { + reserveLoopbackPort as reservePort, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { isExpectedBrowserRequestFailure } from '../../support/foregroundBoundedOutputWebDriver.js'; import { buildRealApiRuntimeConfig, @@ -30,23 +34,6 @@ function browserCacheRoot(): string { throw new Error('Unable to locate the qualified Playwright browser cache'); } -async function reservePort(): Promise { - const server = createTcpServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve embedded-browser Web port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - async function waitFor( predicate: () => boolean | Promise, message: string, @@ -65,36 +52,6 @@ async function waitFor( throw new Error(message, { cause }); } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Embedded-browser Web server did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - async function closeServer(server: Server): Promise { if (!server.listening) return; await new Promise((resolve, reject) => { diff --git a/packages/cli/tests/integration/real-api/compaction-memory-consolidation-trajectory.test.ts b/packages/cli/tests/integration/real-api/compaction-memory-consolidation-trajectory.test.ts index 62c652d20..be562f177 100644 --- a/packages/cli/tests/integration/real-api/compaction-memory-consolidation-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/compaction-memory-consolidation-trajectory.test.ts @@ -31,11 +31,12 @@ import { resolveModelConfig } from '../../../src/services/pi/resolveModelConfig. import { SessionService } from '../../../src/services/SessionService.js'; import { getState } from '../../../src/store/vanilla.js'; import { runWithCwdOverride } from '../../../src/utils/cwd.js'; -import { removeTestDirectory } from '../../support/helpers/removeTestDirectory.js'; +import { reserveLoopbackPort as reservePort } from '../../support/asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, stopForegroundGuiLauncher, } from '../../support/foregroundBoundedOutputWebDriver.js'; +import { removeTestDirectory } from '../../support/helpers/removeTestDirectory.js'; import { OpenAIResponseSummaryCollector, type RecordingProviderResponseSummary, @@ -967,19 +968,6 @@ async function runRunner( return evidence; } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address() as AddressInfo; - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} - async function waitFor( predicate: () => boolean | Promise, message: string, diff --git a/packages/cli/tests/integration/real-api/foreground-command-handoff-trajectory.test.ts b/packages/cli/tests/integration/real-api/foreground-command-handoff-trajectory.test.ts index b56206d9d..f2f525980 100644 --- a/packages/cli/tests/integration/real-api/foreground-command-handoff-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/foreground-command-handoff-trajectory.test.ts @@ -11,6 +11,10 @@ import { type ProcessIdentity, processIdentityMatches, } from '../../../src/utils/process/ProcessIdentity.js'; +import { + reserveLoopbackPort as reservePort, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { isCompleteRawPtyMarkerEvidence } from '../../support/foregroundBoundedOutputPtyDriver.js'; import { createForegroundCommandHandoffFixture, @@ -122,54 +126,6 @@ async function waitFor( throw new Error(message, { cause: lastError }); } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Foreground handoff surface process did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve foreground handoff Web port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - async function initializeWorkspace(workspace: string): Promise { await mkdir(workspace, { recursive: true }); await writeFile(path.join(workspace, 'README.md'), '# Foreground handoff\n'); diff --git a/packages/cli/tests/integration/real-api/goal-execution-host-failure-trajectory.test.ts b/packages/cli/tests/integration/real-api/goal-execution-host-failure-trajectory.test.ts index ae6d111cb..87ffe6cbb 100644 --- a/packages/cli/tests/integration/real-api/goal-execution-host-failure-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/goal-execution-host-failure-trajectory.test.ts @@ -15,6 +15,10 @@ import { } from 'vitest'; import { resetProjectionDbCache } from '../../../src/context/storage/sqlite/projection.js'; import { GoalStore } from '../../../src/goals/GoalStore.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, +} from '../../support/asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -184,33 +188,6 @@ async function createFixture( return fixture; } -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('No Web port'); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} - -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function waitForHttp(origin: string): Promise { await waitFor(async () => { try { diff --git a/packages/cli/tests/integration/real-api/goal-turn-lineage-trajectory.test.ts b/packages/cli/tests/integration/real-api/goal-turn-lineage-trajectory.test.ts index 94f79778d..c5351e97c 100644 --- a/packages/cli/tests/integration/real-api/goal-turn-lineage-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/goal-turn-lineage-trajectory.test.ts @@ -17,6 +17,10 @@ import { PersistentStore } from '../../../src/context/storage/PersistentStore.js import { resetProjectionDbCache } from '../../../src/context/storage/sqlite/projection.js'; import { GoalStore } from '../../../src/goals/GoalStore.js'; import type { GoalTurnLineage } from '../../../src/goals/types.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, +} from '../../support/asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -225,33 +229,6 @@ async function createFixture(model: TestModelConfig): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('No Web port'); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} - -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function waitForHttp(origin: string): Promise { await waitFor(async () => { try { diff --git a/packages/cli/tests/integration/real-api/graceful-shutdown-trajectory.test.ts b/packages/cli/tests/integration/real-api/graceful-shutdown-trajectory.test.ts index d59cd195b..607ade72e 100644 --- a/packages/cli/tests/integration/real-api/graceful-shutdown-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/graceful-shutdown-trajectory.test.ts @@ -17,6 +17,10 @@ import { describe, expect, it } from 'vitest'; import { INTERRUPTED_TURN_MARKER } from '../../../src/agent/loop/conversationPersistence.js'; import { PermissionMode, type RuntimeConfig } from '../../../src/config/types.js'; import { WorkspaceTrustService } from '../../../src/security/WorkspaceTrustService.js'; +import { + reserveLoopbackPort as reservePort, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; import { assertNoForegroundLeases } from './foregroundBoundedOutputHarness.js'; import { @@ -213,54 +217,6 @@ async function waitForProcessGone(pid: number): Promise { }, 15_000); } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Blade child did not exit after graceful shutdown')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Web qualification port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - async function waitForHttp(url: string): Promise { await waitForValue(async () => { try { diff --git a/packages/cli/tests/integration/real-api/prompt-cache-surface-trajectory.test.ts b/packages/cli/tests/integration/real-api/prompt-cache-surface-trajectory.test.ts index 5bf71f440..ee12a5f7a 100644 --- a/packages/cli/tests/integration/real-api/prompt-cache-surface-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/prompt-cache-surface-trajectory.test.ts @@ -6,6 +6,10 @@ import path from 'node:path'; import { promisify } from 'node:util'; import { chromium, type Page } from 'playwright'; import { describe, expect, it } from 'vitest'; +import { + reserveLoopbackPort as reservePort, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { buildRealApiRuntimeConfig, getModelConfig, @@ -22,23 +26,6 @@ const ptyRunner = path.resolve( '../../support/promptCacheStatusPtyRunner.ts' ); -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve prompt cache Web port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - async function waitFor( predicate: () => boolean | Promise, message: string, @@ -57,36 +44,6 @@ async function waitFor( throw new Error(message, { cause: lastError }); } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Prompt cache Web server did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - async function createFixture(prefix: string) { const root = await mkdtemp(path.join(os.tmpdir(), prefix)); const home = path.join(root, 'home'); diff --git a/packages/cli/tests/integration/real-api/provider-attempt-deadline-web-trajectory.test.ts b/packages/cli/tests/integration/real-api/provider-attempt-deadline-web-trajectory.test.ts index 923266d56..370d2bfb1 100644 --- a/packages/cli/tests/integration/real-api/provider-attempt-deadline-web-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/provider-attempt-deadline-web-trajectory.test.ts @@ -5,6 +5,10 @@ import os from 'node:os'; import path from 'node:path'; import { chromium } from 'playwright'; import { describe, expect, it } from 'vitest'; +import { + reserveLoopbackPort as reservePort, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { isRealApiTestEnabled } from './testConfig.js'; interface LauncherReady { @@ -33,23 +37,6 @@ function appendTail(current: string, chunk: Buffer | string): string { return `${current}${chunk.toString()}`.slice(-MAX_OUTPUT_CHARS); } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Provider deadline Web port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - async function waitFor( read: () => T | undefined | Promise, message: string, @@ -136,36 +123,6 @@ function readStopped(output: string): LauncherStopped | undefined { return record as LauncherStopped | undefined; } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Provider deadline launcher did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - describe.skipIf(!enabled)('Provider attempt deadline Web trajectory (real API)', () => { it('renders one fail-closed total deadline in production Chromium', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'blade-provider-deadline-web-')); diff --git a/packages/cli/tests/integration/real-api/providerAdmissionWebTrajectoryHarness.ts b/packages/cli/tests/integration/real-api/providerAdmissionWebTrajectoryHarness.ts index 974dfb490..f3e3e34ab 100644 --- a/packages/cli/tests/integration/real-api/providerAdmissionWebTrajectoryHarness.ts +++ b/packages/cli/tests/integration/real-api/providerAdmissionWebTrajectoryHarness.ts @@ -9,6 +9,11 @@ import { captureProcessIdentity, processIdentityMatches, } from '../../../src/utils/process/ProcessIdentity.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; import { @@ -62,72 +67,6 @@ function safeSlug(value: string): string { .replace(/^-+|-+$/g, ''); } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Web Provider admission server did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Web Provider admission port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - async function createSession( origin: string, workspace: string, diff --git a/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts b/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts index 1baf9f3f3..1669bca72 100644 --- a/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts +++ b/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts @@ -12,6 +12,10 @@ import { type ProcessIdentity, processIdentityMatches, } from '../../../src/utils/process/ProcessIdentity.js'; +import { + reserveLoopbackPort as reservePort, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { createSplitPtyMarkerInstruction, isCompleteRawPtyMarkerEvidence, @@ -210,36 +214,6 @@ async function waitFor( throw new Error(message, { cause: lastError }); } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Provider recovery surface process did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - function upstreamUrl(baseUrl: string, requestUrl: string | undefined): URL { const target = new URL(baseUrl); const incoming = new URL(requestUrl ?? '/', 'http://127.0.0.1'); @@ -681,24 +655,6 @@ async function runSubprocessRunner(input: { return parsed; } -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Provider recovery Web port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - async function waitForHttp(url: string): Promise { await waitFor( async () => { diff --git a/packages/cli/tests/integration/real-api/session-runtime-residency-acp-trajectory.test.ts b/packages/cli/tests/integration/real-api/session-runtime-residency-acp-trajectory.test.ts index ad62a98f1..ed8806bb2 100644 --- a/packages/cli/tests/integration/real-api/session-runtime-residency-acp-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/session-runtime-residency-acp-trajectory.test.ts @@ -16,6 +16,7 @@ import { type ProcessIdentity, processIdentityMatches, } from '../../../src/utils/process/ProcessIdentity.js'; +import { waitForCondition as waitFor } from '../../support/asyncTestUtils.js'; import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; import { @@ -44,19 +45,6 @@ function safeSlug(value: string): string { .replace(/^-+|-+$/g, ''); } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function readTree(root: string): Promise { const entries = await readdir(root, { withFileTypes: true }); const values: string[] = []; diff --git a/packages/cli/tests/integration/real-api/session-runtime-residency-web-trajectory.test.ts b/packages/cli/tests/integration/real-api/session-runtime-residency-web-trajectory.test.ts index b5d4c4b4a..d86d6f273 100644 --- a/packages/cli/tests/integration/real-api/session-runtime-residency-web-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/session-runtime-residency-web-trajectory.test.ts @@ -9,6 +9,11 @@ import { captureProcessIdentity, processIdentityMatches, } from '../../../src/utils/process/ProcessIdentity.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; import { @@ -52,72 +57,6 @@ async function forceRm(target: string): Promise { } } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Session residency Web server did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Session residency Web port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - async function createSession( origin: string, workspace: string, diff --git a/packages/cli/tests/integration/real-api/tool-admission-trajectory.test.ts b/packages/cli/tests/integration/real-api/tool-admission-trajectory.test.ts index 14c8bdda9..431d5d9aa 100644 --- a/packages/cli/tests/integration/real-api/tool-admission-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/tool-admission-trajectory.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from 'vitest'; import { PermissionMode, type RuntimeConfig } from '../../../src/config/types.js'; import type { SessionEvent } from '../../../src/context/types.js'; import { WorkspaceTrustService } from '../../../src/security/WorkspaceTrustService.js'; +import { reserveLoopbackPort as reservePort } from '../../support/asyncTestUtils.js'; import { assertSplitPtyMarkerInstructionAtEnd, createSplitPtyMarkerInstruction, @@ -309,24 +310,6 @@ async function directoryEntries(directory: string): Promise { } } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Web qualification port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - async function waitForHttp(url: string): Promise { await waitFor( async () => { diff --git a/packages/cli/tests/integration/real-api/tui-task-attention-trajectory.test.ts b/packages/cli/tests/integration/real-api/tui-task-attention-trajectory.test.ts index 14ebf5352..79749e63d 100644 --- a/packages/cli/tests/integration/real-api/tui-task-attention-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/tui-task-attention-trajectory.test.ts @@ -9,6 +9,7 @@ import { PermissionMode } from '../../../src/config/types.js'; import { resetProjectionDbCache } from '../../../src/context/storage/sqlite/projection.js'; import { SessionService } from '../../../src/services/SessionService.js'; import type { ProcessIdentity } from '../../../src/utils/process/ProcessIdentity.js'; +import { reserveLoopbackPort as reservePort } from '../../support/asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, stopForegroundGuiLauncher, @@ -56,23 +57,6 @@ function frameworkRetryBudget(context: TestContext): number { return typeof retry === 'number' ? retry : (retry?.count ?? 0); } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve TUI attention server port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - function serverExited(child: ChildProcess): boolean { return child.exitCode !== null || child.signalCode !== null; } diff --git a/packages/cli/tests/integration/real-api/turn-activity-surface-trajectory.test.ts b/packages/cli/tests/integration/real-api/turn-activity-surface-trajectory.test.ts index 9328d6d32..fc051f99f 100644 --- a/packages/cli/tests/integration/real-api/turn-activity-surface-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/turn-activity-surface-trajectory.test.ts @@ -19,11 +19,15 @@ import { SessionSchema } from '../../../src/api/schemas.js'; import { TurnActivityProjectionSchema } from '../../../src/api/turnActivitySchemas.js'; import type { SessionEvent } from '../../../src/context/types.js'; import { SessionService } from '../../../src/services/SessionService.js'; -import { removeTestDirectory } from '../../support/helpers/removeTestDirectory.js'; +import { + reserveLoopbackPort as reservePort, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, stopForegroundGuiLauncher, } from '../../support/foregroundBoundedOutputWebDriver.js'; +import { removeTestDirectory } from '../../support/helpers/removeTestDirectory.js'; import { type RecordingProviderProxy, startRecordingProviderProxy, @@ -102,36 +106,6 @@ function childEnvironment( }; } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Turn activity child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - async function waitFor( predicate: () => boolean | Promise, message: string, @@ -150,21 +124,6 @@ async function waitFor( throw new Error(message, { cause: lastError }); } -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') - throw new Error('Unable to reserve port'); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} - async function openEventProbe( origin: string, sessionId: string, diff --git a/packages/cli/tests/integration/real-api/weighted-task-admission-acp-trajectory.test.ts b/packages/cli/tests/integration/real-api/weighted-task-admission-acp-trajectory.test.ts index 15cbc5bca..649ae0f31 100644 --- a/packages/cli/tests/integration/real-api/weighted-task-admission-acp-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/weighted-task-admission-acp-trajectory.test.ts @@ -8,6 +8,7 @@ import { type ProcessIdentity, processIdentityMatches, } from '../../../src/utils/process/ProcessIdentity.js'; +import { waitForCondition as waitFor } from '../../support/asyncTestUtils.js'; import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; import { @@ -36,19 +37,6 @@ function safeSlug(value: string): string { .replace(/^-+|-+$/g, ''); } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - describe .skipIf(!isRealApiTestEnabled()) .sequential('bounded weighted task admission ACP control', () => { diff --git a/packages/cli/tests/integration/real-api/weighted-task-admission-web-trajectory.test.ts b/packages/cli/tests/integration/real-api/weighted-task-admission-web-trajectory.test.ts index 51de52309..77e6354e4 100644 --- a/packages/cli/tests/integration/real-api/weighted-task-admission-web-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/weighted-task-admission-web-trajectory.test.ts @@ -9,6 +9,11 @@ import { captureProcessIdentity, processIdentityMatches, } from '../../../src/utils/process/ProcessIdentity.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; import { @@ -32,24 +37,6 @@ function safeSlug(value: string): string { .replace(/^-+|-+$/g, ''); } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - async function taskReadinessDiagnostic(page: Page): Promise { const readiness = page.locator('[data-blade-task-dispatch-ready]'); const composer = page.locator('textarea[data-blade-composer]'); @@ -88,54 +75,6 @@ async function waitForTaskDispatchReady(page: Page): Promise { } } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Weighted task Web server did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -async function reservePort(): Promise { - const server = createNetServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve weighted task Web port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - async function listTasks( origin: string, workspace: string diff --git a/packages/cli/tests/integration/turn-activity-surfaces.test.ts b/packages/cli/tests/integration/turn-activity-surfaces.test.ts index d41ea0752..cae243c04 100644 --- a/packages/cli/tests/integration/turn-activity-surfaces.test.ts +++ b/packages/cli/tests/integration/turn-activity-surfaces.test.ts @@ -8,6 +8,7 @@ import { promisify } from 'node:util'; import { chromium } from 'playwright'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { SessionSchema } from '../../src/api/schemas.js'; +import { reserveLoopbackPort as reservePort } from '../support/asyncTestUtils.js'; import { stopForegroundGuiLauncher } from '../support/foregroundBoundedOutputWebDriver.js'; vi.unmock('node:child_process'); @@ -129,19 +130,6 @@ async function startProvider(marker: string) { }; } -async function reservePort(): Promise { - const server = createHttpServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address() as AddressInfo; - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} - async function waitForHttp(origin: string): Promise { const deadline = Date.now() + 20_000; while (Date.now() < deadline) { diff --git a/packages/cli/tests/support/asyncTestUtils.ts b/packages/cli/tests/support/asyncTestUtils.ts new file mode 100644 index 000000000..d4ac771ff --- /dev/null +++ b/packages/cli/tests/support/asyncTestUtils.ts @@ -0,0 +1,67 @@ +import type { ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; + +export async function waitForCondition( + predicate: () => boolean | Promise, + message: string, + timeoutMs = 60_000 +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + if (await predicate()) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(message, { cause: lastError }); +} + +export function waitForChildExit( + child: ChildProcess, + timeoutMs = 30_000 +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error('Test child process did not exit')); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + child.off('error', onError); + child.off('exit', onExit); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + resolve({ code, signal }); + }; + child.once('error', onError); + child.once('exit', onExit); + }); +} + +export async function reserveLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Unable to reserve test server port'); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return address.port; +} diff --git a/packages/cli/tests/support/backgroundSubagentCompletionWebDriver.ts b/packages/cli/tests/support/backgroundSubagentCompletionWebDriver.ts index 430386dc1..165a5dd3a 100644 --- a/packages/cli/tests/support/backgroundSubagentCompletionWebDriver.ts +++ b/packages/cli/tests/support/backgroundSubagentCompletionWebDriver.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { chromium, type Page } from 'playwright'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; import type { ProcessIdentity } from '../../src/utils/process/ProcessIdentity.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -22,23 +23,6 @@ export interface BackgroundSubagentCompletionWebEvidence { browserFaults: []; } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve background completion browser port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - async function waitForHttp(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/packages/cli/tests/support/browserToolWebDriver.ts b/packages/cli/tests/support/browserToolWebDriver.ts index 140c45adb..5e244fec4 100644 --- a/packages/cli/tests/support/browserToolWebDriver.ts +++ b/packages/cli/tests/support/browserToolWebDriver.ts @@ -5,6 +5,7 @@ import { chromium } from 'playwright'; import { expect } from 'vitest'; import type { ProcessIdentity } from '../../src/utils/process/ProcessIdentity.js'; import type { BrowserToolFixture } from '../integration/real-api/browser-tool-fixture.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -20,24 +21,6 @@ export interface BrowserToolWebEvidence { browserFaults: []; } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Browser Tool Web port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - async function waitForHttp(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/packages/cli/tests/support/durableTaskUnreadWebDriver.ts b/packages/cli/tests/support/durableTaskUnreadWebDriver.ts index 5b98ac6fb..3881acc8c 100644 --- a/packages/cli/tests/support/durableTaskUnreadWebDriver.ts +++ b/packages/cli/tests/support/durableTaskUnreadWebDriver.ts @@ -9,6 +9,7 @@ import { SessionSchema, } from '../../src/api/schemas.js'; import type { ProcessIdentity } from '../../src/utils/process/ProcessIdentity.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -104,23 +105,6 @@ function redacted(value: string, secrets: readonly string[]): string { ); } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve durable unread browser port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - async function waitForHttp(origin: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/packages/cli/tests/support/followUpQueueWebDriver.ts b/packages/cli/tests/support/followUpQueueWebDriver.ts index 9ca3dce4b..2b62811e4 100644 --- a/packages/cli/tests/support/followUpQueueWebDriver.ts +++ b/packages/cli/tests/support/followUpQueueWebDriver.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { chromium, type Page } from 'playwright'; import { SessionSchema } from '../../src/api/schemas.js'; import type { ProcessIdentity } from '../../src/utils/process/ProcessIdentity.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -30,23 +31,6 @@ export interface FollowUpQueueWebEvidence { leakedSecrets: string[]; } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve follow-up queue Web port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - async function waitForHttp(origin: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/packages/cli/tests/support/foregroundBoundedOutputWebDriver.ts b/packages/cli/tests/support/foregroundBoundedOutputWebDriver.ts index 902be7463..1e8959f31 100644 --- a/packages/cli/tests/support/foregroundBoundedOutputWebDriver.ts +++ b/packages/cli/tests/support/foregroundBoundedOutputWebDriver.ts @@ -9,6 +9,7 @@ import { processIdentityMatches, } from '../../src/utils/process/ProcessIdentity.js'; import type { ForegroundBoundedOutputFixture } from '../integration/real-api/foregroundBoundedOutputFixture.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; const GUI_LAUNCHER_IDENTITY_TIMEOUT_MS = 2_000; const GUI_LAUNCHER_IDENTITY_RETRY_MS = 25; @@ -65,24 +66,6 @@ export function parseForegroundGuiReadyLine( } } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve browser qualification port'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - function appendTail(current: string, chunk: Buffer | string): string { const next = current + chunk.toString(); return next.length > 16_384 ? next.slice(-16_384) : next; diff --git a/packages/cli/tests/support/foregroundCommandHandoffAcpRunner.ts b/packages/cli/tests/support/foregroundCommandHandoffAcpRunner.ts index cf55fa46c..c0cfd95ae 100644 --- a/packages/cli/tests/support/foregroundCommandHandoffAcpRunner.ts +++ b/packages/cli/tests/support/foregroundCommandHandoffAcpRunner.ts @@ -4,6 +4,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForChildExit } from './asyncTestUtils.js'; import { driveForegroundCommandHandoffFixture, type ForegroundCommandHandoffFixture, @@ -36,36 +37,6 @@ function childEnvironment(input: RunnerInput): NodeJS.ProcessEnv { }; } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('ACP handoff child did not exit after SIGTERM')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - async function waitFor( predicate: () => boolean, message: string, diff --git a/packages/cli/tests/support/foregroundProviderRecoveryAcpRunner.ts b/packages/cli/tests/support/foregroundProviderRecoveryAcpRunner.ts index 2319da5ef..ad54e8c62 100644 --- a/packages/cli/tests/support/foregroundProviderRecoveryAcpRunner.ts +++ b/packages/cli/tests/support/foregroundProviderRecoveryAcpRunner.ts @@ -8,6 +8,7 @@ import { readSessionEvents, } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -32,49 +33,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('ACP Provider recovery child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function run(input: RunnerInput) { const child = spawn(process.execPath, [input.cliEntry, '--acp'], { cwd: input.workspace, diff --git a/packages/cli/tests/support/goalExecutionHostFailureAcpRunner.ts b/packages/cli/tests/support/goalExecutionHostFailureAcpRunner.ts index ffb56fe25..23ef03d76 100644 --- a/packages/cli/tests/support/goalExecutionHostFailureAcpRunner.ts +++ b/packages/cli/tests/support/goalExecutionHostFailureAcpRunner.ts @@ -2,6 +2,7 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -19,19 +20,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function waitForChildExit( child: ChildProcess, timeoutMs = 10_000 diff --git a/packages/cli/tests/support/goalExecutionHostFailurePtyRunner.ts b/packages/cli/tests/support/goalExecutionHostFailurePtyRunner.ts index f265412bd..ad05d9bf0 100644 --- a/packages/cli/tests/support/goalExecutionHostFailurePtyRunner.ts +++ b/packages/cli/tests/support/goalExecutionHostFailurePtyRunner.ts @@ -1,4 +1,5 @@ import { spawn } from 'bun-pty'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, projectForegroundBoundedPtyOutput, @@ -22,19 +23,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function signalTree(pid: number, signal: NodeJS.Signals, fallback: () => void): void { try { process.kill(-pid, signal); diff --git a/packages/cli/tests/support/goalFinalizationWebDriver.ts b/packages/cli/tests/support/goalFinalizationWebDriver.ts index 0f4e2427a..36f032352 100644 --- a/packages/cli/tests/support/goalFinalizationWebDriver.ts +++ b/packages/cli/tests/support/goalFinalizationWebDriver.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { chromium, type Page } from 'playwright'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; import type { ProcessIdentity } from '../../src/utils/process/ProcessIdentity.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -19,23 +20,6 @@ export interface GoalFinalizationWebEvidence { browserFaults: []; } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve Goal finalization browser port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - async function waitForHttp(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/packages/cli/tests/support/gracefulShutdownAcpRunner.ts b/packages/cli/tests/support/gracefulShutdownAcpRunner.ts index dfa45b99e..8197d272b 100644 --- a/packages/cli/tests/support/gracefulShutdownAcpRunner.ts +++ b/packages/cli/tests/support/gracefulShutdownAcpRunner.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -57,36 +58,6 @@ async function waitForRootPid(filePath: string): Promise { throw new Error('Timed out waiting for ACP foreground process'); } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('ACP child did not exit after shutdown signal')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - function updateShape( updates: readonly acp.SessionNotification[] ): Array<{ kind: string; title?: string; status?: string }> { diff --git a/packages/cli/tests/support/memoryConsolidationAcpRunner.ts b/packages/cli/tests/support/memoryConsolidationAcpRunner.ts index bc4348632..5f9b90b6a 100644 --- a/packages/cli/tests/support/memoryConsolidationAcpRunner.ts +++ b/packages/cli/tests/support/memoryConsolidationAcpRunner.ts @@ -4,6 +4,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -26,36 +27,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Memory consolidation ACP child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - function agentText(client: ChildBackedRecordingAcpClient, sessionId: string): string { return client.sessionUpdates .filter((notification) => notification.sessionId === sessionId) diff --git a/packages/cli/tests/support/providerRequestAdmissionAcpRunner.ts b/packages/cli/tests/support/providerRequestAdmissionAcpRunner.ts index 9b4271d9d..12fff21c2 100644 --- a/packages/cli/tests/support/providerRequestAdmissionAcpRunner.ts +++ b/packages/cli/tests/support/providerRequestAdmissionAcpRunner.ts @@ -4,6 +4,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -22,49 +23,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('ACP Provider admission child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - function agentText(client: ChildBackedRecordingAcpClient, sessionId: string): string { return client.sessionUpdates .filter((notification) => notification.sessionId === sessionId) diff --git a/packages/cli/tests/support/rootTurnAutoResumeWebDriver.ts b/packages/cli/tests/support/rootTurnAutoResumeWebDriver.ts index afce138df..a17500674 100644 --- a/packages/cli/tests/support/rootTurnAutoResumeWebDriver.ts +++ b/packages/cli/tests/support/rootTurnAutoResumeWebDriver.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { chromium, type Page } from 'playwright'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; import type { ProcessIdentity } from '../../src/utils/process/ProcessIdentity.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -22,23 +23,6 @@ export interface RootTurnAutoResumeWebEvidence { const ROOT_TURN_RESPONSE_PREFIX = 'ROOT_TURN_RECOVERED_'; -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve root-turn browser port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - async function waitForHttp(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/packages/cli/tests/support/sessionRuntimeResidencyAcpRunner.ts b/packages/cli/tests/support/sessionRuntimeResidencyAcpRunner.ts index 9175f5c9b..0679e7540 100644 --- a/packages/cli/tests/support/sessionRuntimeResidencyAcpRunner.ts +++ b/packages/cli/tests/support/sessionRuntimeResidencyAcpRunner.ts @@ -7,6 +7,7 @@ import { readSessionEvents, } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -28,49 +29,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Session residency ACP child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - function prompt(connection: acp.ClientSideConnection, sessionId: string, text: string) { return connection.prompt({ sessionId, diff --git a/packages/cli/tests/support/subagentResultAdoptionWebDriver.ts b/packages/cli/tests/support/subagentResultAdoptionWebDriver.ts index 07c1b3604..2a1ae98b8 100644 --- a/packages/cli/tests/support/subagentResultAdoptionWebDriver.ts +++ b/packages/cli/tests/support/subagentResultAdoptionWebDriver.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { chromium, type Page } from 'playwright'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; import type { ProcessIdentity } from '../../src/utils/process/ProcessIdentity.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -18,23 +19,6 @@ export interface SubagentResultAdoptionWebEvidence { browserFaults: []; } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to reserve subagent adoption browser port'); - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return address.port; -} - async function waitForHttp(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/packages/cli/tests/support/taskOwnerIdentitySurfaceQualification.ts b/packages/cli/tests/support/taskOwnerIdentitySurfaceQualification.ts index 5e205bc62..ac233d6a0 100644 --- a/packages/cli/tests/support/taskOwnerIdentitySurfaceQualification.ts +++ b/packages/cli/tests/support/taskOwnerIdentitySurfaceQualification.ts @@ -18,6 +18,7 @@ import { chromium } from 'playwright'; import { SessionLease } from '../../src/agent/runtime/SessionLease.js'; import { getProjectStoragePath } from '../../src/context/storage/pathUtils.js'; import { SessionService } from '../../src/services/SessionService.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; import { createTuiPtyEnvironment } from './ptyInput.js'; const root = await realpath( @@ -29,19 +30,6 @@ const workspace = path.join(root, 'team-project_with_underscore'); const storage = path.join(root, 'storage'); const home = path.join(root, 'home'); const repo = path.resolve(import.meta.dirname, '../../../..'); -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - assert(address && typeof address !== 'string'); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); - return address.port; -} const backendPort = await reservePort(); const frontendPort = await reservePort(); await mkdir(workspace, { recursive: true }); diff --git a/packages/cli/tests/support/tokenBudgetHandoffWebDriver.ts b/packages/cli/tests/support/tokenBudgetHandoffWebDriver.ts index 316d32d23..83a127d41 100644 --- a/packages/cli/tests/support/tokenBudgetHandoffWebDriver.ts +++ b/packages/cli/tests/support/tokenBudgetHandoffWebDriver.ts @@ -8,6 +8,7 @@ import { processIdentityMatches } from '../../src/utils/process/ProcessIdentity. import { assertNoSecrets } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import type { TokenBudgetHandoffFixture } from '../integration/real-api/tokenBudgetHandoffFixture.js'; import type { TokenBudgetHandoffSurfaceEvidence } from '../integration/real-api/tokenBudgetHandoffHarness.js'; +import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -352,24 +353,6 @@ export function parseTokenBudgetHandoffWebEvidence( }; } -async function reservePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Token-budget Web port reservation failed'); - } - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - async function assertPortReusable(port: number): Promise { const server = createServer(); await new Promise((resolve, reject) => { diff --git a/packages/cli/tests/support/toolAdmissionAcpRunner.ts b/packages/cli/tests/support/toolAdmissionAcpRunner.ts index c47dee1ab..606bf8d9c 100644 --- a/packages/cli/tests/support/toolAdmissionAcpRunner.ts +++ b/packages/cli/tests/support/toolAdmissionAcpRunner.ts @@ -5,6 +5,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForChildExit } from './asyncTestUtils.js'; import { driveToolAdmissionFixture, TOOL_ADMISSION_CALL_IDS, @@ -39,36 +40,6 @@ function childEnvironment(input: RunnerInput): NodeJS.ProcessEnv { }; } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('ACP child did not exit after SIGTERM')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - async function waitFor( predicate: () => boolean, message: string, diff --git a/packages/cli/tests/support/turnActivityAcpRunner.ts b/packages/cli/tests/support/turnActivityAcpRunner.ts index faf7bfc5d..c85656cad 100644 --- a/packages/cli/tests/support/turnActivityAcpRunner.ts +++ b/packages/cli/tests/support/turnActivityAcpRunner.ts @@ -4,6 +4,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import type { TurnActivityProjection } from '../../src/api/turnActivitySchemas.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForChildExit } from './asyncTestUtils.js'; import { createTuiTaskAttentionRunnerEnvironment } from './tuiTaskAttentionPtyDriver.js'; interface RunnerInput { @@ -30,36 +31,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Turn activity ACP child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - async function waitFor( predicate: () => boolean, message: string, diff --git a/packages/cli/tests/support/weightedProviderAdmissionAcpRunner.ts b/packages/cli/tests/support/weightedProviderAdmissionAcpRunner.ts index bd7af2cfb..7df5ed1d9 100644 --- a/packages/cli/tests/support/weightedProviderAdmissionAcpRunner.ts +++ b/packages/cli/tests/support/weightedProviderAdmissionAcpRunner.ts @@ -4,6 +4,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -22,49 +23,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Weighted ACP admission child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - function agentText(client: ChildBackedRecordingAcpClient, sessionId: string): string { return client.sessionUpdates .filter((notification) => notification.sessionId === sessionId) diff --git a/packages/cli/tests/support/weightedTaskAdmissionAcpRunner.ts b/packages/cli/tests/support/weightedTaskAdmissionAcpRunner.ts index c78baa825..2e64a309d 100644 --- a/packages/cli/tests/support/weightedTaskAdmissionAcpRunner.ts +++ b/packages/cli/tests/support/weightedTaskAdmissionAcpRunner.ts @@ -4,6 +4,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -24,49 +25,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 60_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 30_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Weighted task ACP child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - function agentText(client: ChildBackedRecordingAcpClient, sessionId: string): string { return client.sessionUpdates .filter((notification) => notification.sessionId === sessionId) From ce0758195db599407ca7629621d6af5cdfff809f Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:47:03 +0800 Subject: [PATCH 15/59] refactor(test): centralize polling utilities --- .../browser-preview-trajectory.test.ts | 46 +++----- ...on-memory-consolidation-trajectory.test.ts | 33 +++--- ...eground-command-handoff-trajectory.test.ts | 25 +--- ...oordination-reclamation-trajectory.test.ts | 71 +++++------ .../prompt-cache-surface-trajectory.test.ts | 37 ++---- .../providerRecoveryTrajectoryHarness.ts | 19 +-- ...time-residency-controls-trajectory.test.ts | 31 +---- .../tool-admission-trajectory.test.ts | 95 +++++---------- .../turn-activity-surface-trajectory.test.ts | 22 +--- .../backgroundSubagentCompletionPtyRunner.ts | 22 +--- .../cli/tests/support/browserToolPtyRunner.ts | 22 +--- .../tests/support/followUpQueueAcpRunner.ts | 14 +-- .../tests/support/followUpQueuePtyRunner.ts | 14 +-- .../tests/support/followUpQueueWebDriver.ts | 18 +-- .../foregroundBoundedOutputPtyRunner.ts | 22 +--- .../foregroundCommandHandoffAcpRunner.ts | 33 ++---- .../foregroundCommandHandoffFixtureDriver.ts | 111 +++++++++--------- .../foregroundCommandHandoffPtyRunner.ts | 20 +--- .../foregroundProviderRecoveryPtyRunner.ts | 14 +-- .../goalExecutionHostFailureAcpRunner.ts | 34 +----- .../support/goalFinalizationPtyRunner.ts | 22 +--- .../tests/support/goalTurnLineageAcpRunner.ts | 49 +------- .../tests/support/goalTurnLineagePtyRunner.ts | 40 +++---- .../support/gracefulShutdownPtyRunner.ts | 20 +--- .../support/launch-session-surface-gui.ts | 31 +---- .../support/memoryConsolidationPtyRunner.ts | 34 +++--- .../support/promptCacheStatusPtyRunner.ts | 22 +--- .../support/rootTurnAutoResumePtyRunner.ts | 22 +--- .../sessionRuntimeResidencyPtyRunner.ts | 14 +-- .../sideConversationCancellationRunner.ts | 111 ++++++++++-------- .../subagentResultAdoptionPtyRunner.ts | 22 +--- .../tests/support/toolAdmissionAcpRunner.ts | 18 +-- .../support/toolAdmissionFixtureDriver.ts | 72 +++++------- .../tests/support/toolAdmissionPtyRunner.ts | 20 +--- packages/cli/tests/support/tuiPtyRunner.ts | 22 +--- .../support/tuiTaskAttentionPtyRunner.ts | 14 +-- .../tests/support/turnActivityAcpRunner.ts | 18 +-- .../tests/support/turnActivityPtyRunner.ts | 30 ++--- .../weightedProviderAdmissionPtyRunner.ts | 22 +--- 39 files changed, 363 insertions(+), 943 deletions(-) diff --git a/packages/cli/tests/integration/real-api/browser-preview-trajectory.test.ts b/packages/cli/tests/integration/real-api/browser-preview-trajectory.test.ts index 2daa17c67..e0543792c 100644 --- a/packages/cli/tests/integration/real-api/browser-preview-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/browser-preview-trajectory.test.ts @@ -10,6 +10,7 @@ import { chromium } from 'playwright'; import { describe, expect, it } from 'vitest'; import { reserveLoopbackPort as reservePort, + waitForCondition as waitFor, waitForChildExit, } from '../../support/asyncTestUtils.js'; import { isExpectedBrowserRequestFailure } from '../../support/foregroundBoundedOutputWebDriver.js'; @@ -34,24 +35,6 @@ function browserCacheRoot(): string { throw new Error('Unable to locate the qualified Playwright browser cache'); } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 30_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let cause: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - cause = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause }); -} - async function closeServer(server: Server): Promise { if (!server.listening) return; await new Promise((resolve, reject) => { @@ -194,13 +177,17 @@ describe }); const origin = `http://127.0.0.1:${appPort}`; - await waitFor(async () => { - try { - return (await fetch(`${origin}/health`)).ok; - } catch { - return false; - } - }, 'Embedded-browser Blade server did not become ready'); + await waitFor( + async () => { + try { + return (await fetch(`${origin}/health`)).ok; + } catch { + return false; + } + }, + 'Embedded-browser Blade server did not become ready', + 30_000 + ); const create = await fetch(`${origin}/sessions`, { method: 'POST', @@ -358,7 +345,8 @@ describe await page.getByRole('button', { name: 'Reload page' }).click(); await waitFor( () => (fixtureRequests.get('/two') ?? 0) > requestsBeforeReload, - 'Embedded-browser reload did not request the current page' + 'Embedded-browser reload did not request the current page', + 30_000 ); const popupPromise = page.waitForEvent('popup'); @@ -399,7 +387,8 @@ describe async () => (await testPanel.getAttribute('data-browser-snapshot-id')) !== initialSnapshotId, - 'Browser Panel did not publish the post-fill snapshot' + 'Browser Panel did not publish the post-fill snapshot', + 30_000 ); await testPanel .locator('[data-browser-ref]') @@ -415,7 +404,8 @@ describe await waitFor( async () => (await testPanel.textContent())?.includes('browser-panel-applied') === true, - 'Browser Panel console diagnostics did not include the fixture marker' + 'Browser Panel console diagnostics did not include the fixture marker', + 30_000 ); await page.setViewportSize({ width: 390, height: 844 }); diff --git a/packages/cli/tests/integration/real-api/compaction-memory-consolidation-trajectory.test.ts b/packages/cli/tests/integration/real-api/compaction-memory-consolidation-trajectory.test.ts index be562f177..3b6880335 100644 --- a/packages/cli/tests/integration/real-api/compaction-memory-consolidation-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/compaction-memory-consolidation-trajectory.test.ts @@ -31,7 +31,10 @@ import { resolveModelConfig } from '../../../src/services/pi/resolveModelConfig. import { SessionService } from '../../../src/services/SessionService.js'; import { getState } from '../../../src/store/vanilla.js'; import { runWithCwdOverride } from '../../../src/utils/cwd.js'; -import { reserveLoopbackPort as reservePort } from '../../support/asyncTestUtils.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, +} from '../../support/asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, stopForegroundGuiLauncher, @@ -968,19 +971,6 @@ async function runRunner( return evidence; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 180_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(message); -} - async function openEventProbe( origin: string, sessionId: string, @@ -1197,7 +1187,8 @@ async function runAutoCompactionWeb( } await waitFor( () => probe?.events.some((event) => event.type === 'session.completed') === true, - 'Warmup did not complete' + 'Warmup did not complete', + 180_000 ); const meter = page.locator('[data-chat-status-bar] > div').first(); await waitFor( @@ -1215,7 +1206,8 @@ async function runAutoCompactionWeb( () => true, () => false ), - 'Auto summary retry did not begin' + 'Auto summary retry did not begin', + 180_000 ); const stopStarted = Date.now(); const cancelRequests: Array<{ method: string; path: string; elapsedMs: number }> = @@ -1413,7 +1405,8 @@ async function runWeb(test: Fixture): Promise { topics: ['conventions'], }) ) === true, - 'Memory Web did not complete consolidation' + 'Memory Web did not complete consolidation', + 180_000 ); await page.waitForFunction( () => @@ -1427,7 +1420,8 @@ async function runWeb(test: Fixture): Promise { await waitFor( () => primary?.events.some((event) => event.type === 'session.completed') === true, - 'Memory Web primary Session did not complete' + 'Memory Web primary Session did not complete', + 180_000 ); const events = readSessionEvents( findSessionTranscript(test.storageRoot, test.sessionId) @@ -1462,7 +1456,8 @@ async function runWeb(test: Fixture): Promise { await waitFor( () => discovery?.events.some((event) => event.type === 'session.completed') === true, - 'Memory Web discovery Session did not complete' + 'Memory Web discovery Session did not complete', + 180_000 ); expect(JSON.stringify(discovery.events)).toContain(test.discoveryMarker); assertNoSecrets( diff --git a/packages/cli/tests/integration/real-api/foreground-command-handoff-trajectory.test.ts b/packages/cli/tests/integration/real-api/foreground-command-handoff-trajectory.test.ts index f2f525980..6b89e543f 100644 --- a/packages/cli/tests/integration/real-api/foreground-command-handoff-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/foreground-command-handoff-trajectory.test.ts @@ -13,6 +13,7 @@ import { } from '../../../src/utils/process/ProcessIdentity.js'; import { reserveLoopbackPort as reservePort, + waitForCondition as waitFor, waitForChildExit, } from '../../support/asyncTestUtils.js'; import { isCompleteRawPtyMarkerEvidence } from '../../support/foregroundBoundedOutputPtyDriver.js'; @@ -108,24 +109,6 @@ function headlessContent(output: string): string { .join(''); } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 120_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - async function initializeWorkspace(workspace: string): Promise { await mkdir(workspace, { recursive: true }); await writeFile(path.join(workspace, 'README.md'), '# Foreground handoff\n'); @@ -244,7 +227,8 @@ async function runHeadless(input: { await waitFor( () => output.includes(shellId) && output.includes('"auto_backgrounded":true'), - 'Headless JSONL did not project foreground handoff metadata' + 'Headless JSONL did not project foreground handoff metadata', + 120_000 ); }, }); @@ -514,7 +498,8 @@ async function runWeb(input: { (event.properties.metadata as Record).shell_id === shellId ) === true, - 'Web SSE did not project foreground handoff metadata' + 'Web SSE did not project foreground handoff metadata', + 120_000 ); await expandToolGroups(page); const card = page diff --git a/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts b/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts index bdac99aa1..e8b2003e9 100644 --- a/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts @@ -18,6 +18,7 @@ import { sessionInteractionCoordinationStatsForTests } from '../../../src/servic import { SkillRegistry } from '../../../src/skills/SkillRegistry.js'; import { ensureStoreInitialized, getState } from '../../../src/store/vanilla.js'; import { runWithCwdOverride } from '../../../src/utils/cwd.js'; +import { waitForCondition as waitFor } from '../../support/asyncTestUtils.js'; import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; import { buildRealApiRuntimeConfig, @@ -117,24 +118,6 @@ async function initializeIsolatedExtensions( subagentRegistry.loadBuiltinAgents(); } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 120_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - function endpoint(server: TestServer, pathname: string): URL { return new URL(pathname.replace(/^\//, ''), server.url); } @@ -177,19 +160,23 @@ async function waitForWebSessionTerminal( workspace: string, sessionId: string ): Promise { - await waitFor(async () => { - const url = endpoint(server, `/sessions/${sessionId}/status`); - url.searchParams.set('projectPath', workspace); - const response = await fetch(url); - if (!response.ok) return false; - const status = (await response.json()) as { status?: unknown }; - return ( - status.status === 'idle' || - status.status === 'completed' || - status.status === 'failed' || - status.status === 'cancelled' - ); - }, `Web Session ${sessionId} did not settle`); + await waitFor( + async () => { + const url = endpoint(server, `/sessions/${sessionId}/status`); + url.searchParams.set('projectPath', workspace); + const response = await fetch(url); + if (!response.ok) return false; + const status = (await response.json()) as { status?: unknown }; + return ( + status.status === 'idle' || + status.status === 'completed' || + status.status === 'failed' || + status.status === 'cancelled' + ); + }, + `Web Session ${sessionId} did not settle`, + 120_000 + ); } async function openWebSession( @@ -263,15 +250,19 @@ function expectSharedCoordinationIdle(): void { } async function waitForWebCoordinationIdle(): Promise { - await waitFor(() => { - const stats = BladeServer.getSessionCoordinationStatsForTests(); - return ( - stats?.messageSubmissions.keys === 0 && - stats.messageSubmissions.operations === 0 && - stats.taskDeliveries.keys === 0 && - stats.taskDeliveries.operations === 0 - ); - }, 'Web keyed coordination did not return to zero'); + await waitFor( + () => { + const stats = BladeServer.getSessionCoordinationStatsForTests(); + return ( + stats?.messageSubmissions.keys === 0 && + stats.messageSubmissions.operations === 0 && + stats.taskDeliveries.keys === 0 && + stats.taskDeliveries.operations === 0 + ); + }, + 'Web keyed coordination did not return to zero', + 120_000 + ); expect(BladeServer.getSessionCoordinationStatsForTests()).toEqual({ messageSubmissions: { keys: 0, operations: 0 }, taskDeliveries: { keys: 0, operations: 0 }, diff --git a/packages/cli/tests/integration/real-api/prompt-cache-surface-trajectory.test.ts b/packages/cli/tests/integration/real-api/prompt-cache-surface-trajectory.test.ts index ee12a5f7a..c7331b995 100644 --- a/packages/cli/tests/integration/real-api/prompt-cache-surface-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/prompt-cache-surface-trajectory.test.ts @@ -8,6 +8,7 @@ import { chromium, type Page } from 'playwright'; import { describe, expect, it } from 'vitest'; import { reserveLoopbackPort as reservePort, + waitForCondition as waitFor, waitForChildExit, } from '../../support/asyncTestUtils.js'; import { @@ -26,24 +27,6 @@ const ptyRunner = path.resolve( '../../support/promptCacheStatusPtyRunner.ts' ); -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 30_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - async function createFixture(prefix: string) { const root = await mkdtemp(path.join(os.tmpdir(), prefix)); const home = path.join(root, 'home'); @@ -145,13 +128,17 @@ describe.skipIf(!enabled).sequential('Prompt cache surfaces (production)', () => try { const origin = `http://127.0.0.1:${port}`; - await waitFor(async () => { - try { - return (await fetch(`${origin}/health`)).ok; - } catch { - return false; - } - }, 'Prompt cache Web server did not become ready'); + await waitFor( + async () => { + try { + return (await fetch(`${origin}/health`)).ok; + } catch { + return false; + } + }, + 'Prompt cache Web server did not become ready', + 30_000 + ); const sessionId = await createSession(origin, fixture.workspace); const navigation = new URL(origin); navigation.searchParams.set('session', sessionId); diff --git a/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts b/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts index 1669bca72..4852dc472 100644 --- a/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts +++ b/packages/cli/tests/integration/real-api/providerRecoveryTrajectoryHarness.ts @@ -14,6 +14,7 @@ import { } from '../../../src/utils/process/ProcessIdentity.js'; import { reserveLoopbackPort as reservePort, + waitForCondition as waitFor, waitForChildExit, } from '../../support/asyncTestUtils.js'; import { @@ -196,24 +197,6 @@ function appendTail(current: string, chunk: Buffer | string): string { return `${current}${chunk.toString()}`.slice(-MAX_CAPTURE_CHARS); } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 120_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - function upstreamUrl(baseUrl: string, requestUrl: string | undefined): URL { const target = new URL(baseUrl); const incoming = new URL(requestUrl ?? '/', 'http://127.0.0.1'); diff --git a/packages/cli/tests/integration/real-api/session-runtime-residency-controls-trajectory.test.ts b/packages/cli/tests/integration/real-api/session-runtime-residency-controls-trajectory.test.ts index 0474bfe51..112e3a3a4 100644 --- a/packages/cli/tests/integration/real-api/session-runtime-residency-controls-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/session-runtime-residency-controls-trajectory.test.ts @@ -10,6 +10,7 @@ import { type ProcessIdentity, processIdentityMatches, } from '../../../src/utils/process/ProcessIdentity.js'; +import { waitForChildExit } from '../../support/asyncTestUtils.js'; import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; import { @@ -60,36 +61,6 @@ function childEnvironment(home: string, storageRoot: string): NodeJS.ProcessEnv }; } -function waitForChildExit( - child: ChildProcess, - timeoutMs: number -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Session residency Headless process did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - async function runHeadless(input: { workspace: string; home: string; diff --git a/packages/cli/tests/integration/real-api/tool-admission-trajectory.test.ts b/packages/cli/tests/integration/real-api/tool-admission-trajectory.test.ts index 431d5d9aa..4ecf1f7d9 100644 --- a/packages/cli/tests/integration/real-api/tool-admission-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/tool-admission-trajectory.test.ts @@ -18,7 +18,11 @@ import { describe, expect, it } from 'vitest'; import { PermissionMode, type RuntimeConfig } from '../../../src/config/types.js'; import type { SessionEvent } from '../../../src/context/types.js'; import { WorkspaceTrustService } from '../../../src/security/WorkspaceTrustService.js'; -import { reserveLoopbackPort as reservePort } from '../../support/asyncTestUtils.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, + waitForChildExit, +} from '../../support/asyncTestUtils.js'; import { assertSplitPtyMarkerInstructionAtEnd, createSplitPtyMarkerInstruction, @@ -238,54 +242,6 @@ function childEnvironment(home: string, storageRoot: string): NodeJS.ProcessEnv }; } -async function waitFor( - predicate: () => Promise | boolean, - message: string, - timeoutMs = 120_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 180_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Blade child did not exit after tool admission completed')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - async function releaseCalls( stateDir: string, callIds: readonly string[] @@ -483,7 +439,8 @@ async function waitForQueuedToolCards( event.type === 'tool.progress' && event.properties.message === 'Waiting for tool execution capacity' ).length >= expected, - `Web SSE did not publish ${expected} queued tool call(s)` + `Web SSE did not publish ${expected} queued tool call(s)`, + 120_000 ); const toolGroup = page.locator('[data-agent-tool-group]').last(); try { @@ -510,7 +467,8 @@ async function waitForQueuedToolCards( (await details .getByText('Waiting for tool execution capacity', { exact: true }) .count()) >= expected, - `Web GUI did not render ${expected} queued tool card(s)` + `Web GUI did not render ${expected} queued tool card(s)`, + 120_000 ); } @@ -552,7 +510,8 @@ async function waitForBashPartCount( ): Promise { await waitFor( () => bashPartCount(storageRoot, sessionId, partType) === expected, - `Session ${sessionId} did not persist ${expected} Bash ${partType} part(s)` + `Session ${sessionId} did not persist ${expected} Bash ${partType} part(s)`, + 120_000 ); } @@ -561,13 +520,17 @@ async function waitForBashSessionCompletion( sessionId: string, expectedResults: number ): Promise { - await waitFor(() => { - const events = readSessionEvents(findSessionTranscript(storageRoot, sessionId)); - return ( - bashPartCount(storageRoot, sessionId, 'tool_result') === expectedResults && - events.filter((event) => event.type === 'turn_completed').length === 1 - ); - }, `Session ${sessionId} did not complete with ${expectedResults} Bash result(s)`); + await waitFor( + () => { + const events = readSessionEvents(findSessionTranscript(storageRoot, sessionId)); + return ( + bashPartCount(storageRoot, sessionId, 'tool_result') === expectedResults && + events.filter((event) => event.type === 'turn_completed').length === 1 + ); + }, + `Session ${sessionId} did not complete with ${expectedResults} Bash result(s)`, + 120_000 + ); } async function reloadAndAssertMarker(page: Page, marker: string): Promise { @@ -637,7 +600,8 @@ async function runWebFairnessSurface(input: { (await directoryEntries(path.join(stateDir, 'started'))).filter((callId) => callId.startsWith('session-a-') ).length === 2, - 'Session A did not occupy exactly two execute slots' + 'Session A did not occupy exactly two execute slots', + 120_000 ); const firstWaveA = (await directoryEntries(path.join(stateDir, 'started'))).filter( (callId) => callId.startsWith('session-a-') @@ -654,7 +618,8 @@ async function runWebFairnessSurface(input: { (await directoryEntries(path.join(stateDir, 'started'))).includes( FAIRNESS_B_CALL_IDS[0] ), - 'Session B did not use the remaining global execute slot' + 'Session B did not use the remaining global execute slot', + 120_000 ); expect(await directoryEntries(path.join(stateDir, 'started'))).toEqual( [...firstWaveA, FAIRNESS_B_CALL_IDS[0]].sort() @@ -689,7 +654,8 @@ async function runWebFairnessSurface(input: { (await directoryEntries(path.join(stateDir, 'started'))).filter((callId) => callId.startsWith('session-a-') ).length === 3, - 'Session A did not admit one successor after one permit was released' + 'Session A did not admit one successor after one permit was released', + 120_000 ); const activeA = (await directoryEntries(path.join(stateDir, 'active'))).filter( (callId) => callId.startsWith('session-a-') @@ -814,10 +780,11 @@ async function runHeadlessSurface(input: { waitForQueuedEvidence: () => waitFor( () => countOccurrences(output, 'Waiting for tool execution capacity') >= 2, - 'Headless did not project two queued tool calls' + 'Headless did not project two queued tool calls', + 120_000 ), }); - const exit = await waitForChildExit(child); + const exit = await waitForChildExit(child, 180_000); if (exit.signal || exit.code !== 0) { throw new Error( `Headless admission exit was ${exit.code ?? exit.signal}: ${output diff --git a/packages/cli/tests/integration/real-api/turn-activity-surface-trajectory.test.ts b/packages/cli/tests/integration/real-api/turn-activity-surface-trajectory.test.ts index fc051f99f..ac0d00502 100644 --- a/packages/cli/tests/integration/real-api/turn-activity-surface-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/turn-activity-surface-trajectory.test.ts @@ -21,6 +21,7 @@ import type { SessionEvent } from '../../../src/context/types.js'; import { SessionService } from '../../../src/services/SessionService.js'; import { reserveLoopbackPort as reservePort, + waitForCondition as waitFor, waitForChildExit, } from '../../support/asyncTestUtils.js'; import { @@ -106,24 +107,6 @@ function childEnvironment( }; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 120_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - async function openEventProbe( origin: string, sessionId: string, @@ -2080,7 +2063,8 @@ describeTrajectory('empty final without tools (real API)', () => { readSessionEvents(transcript).some( (event) => event.type === 'turn_completed' ), - 'Empty final follow-up did not commit' + 'Empty final follow-up did not commit', + 120_000 ); const events = readSessionEvents(transcript); expect(toolCallNames(events)).toEqual([]); diff --git a/packages/cli/tests/support/backgroundSubagentCompletionPtyRunner.ts b/packages/cli/tests/support/backgroundSubagentCompletionPtyRunner.ts index 45490db21..5aa88b256 100644 --- a/packages/cli/tests/support/backgroundSubagentCompletionPtyRunner.ts +++ b/packages/cli/tests/support/backgroundSubagentCompletionPtyRunner.ts @@ -1,6 +1,7 @@ import { access } from 'node:fs/promises'; import { spawn } from 'bun-pty'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, latchPtyMarker, @@ -14,27 +15,6 @@ function required(name: string): string { return value; } -function waitFor( - predicate: () => boolean, - message: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - startedAt >= timeoutMs) { - clearInterval(timer); - reject(new Error(message)); - } - }, 50); - }); -} - async function waitForInboxRemoval( workspace: string, sessionId: string, diff --git a/packages/cli/tests/support/browserToolPtyRunner.ts b/packages/cli/tests/support/browserToolPtyRunner.ts index 3ab88fd55..521707d66 100644 --- a/packages/cli/tests/support/browserToolPtyRunner.ts +++ b/packages/cli/tests/support/browserToolPtyRunner.ts @@ -1,5 +1,6 @@ import { spawn } from 'bun-pty'; import { PersistentStore } from '../../src/context/storage/PersistentStore.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, latchPtyMarker, @@ -13,27 +14,6 @@ function required(name: string): string { return value; } -function waitFor( - predicate: () => boolean, - message: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - startedAt >= timeoutMs) { - clearInterval(timer); - reject(new Error(message)); - } - }, 50); - }); -} - async function waitForDurableCompletion( workspace: string, sessionId: string, diff --git a/packages/cli/tests/support/followUpQueueAcpRunner.ts b/packages/cli/tests/support/followUpQueueAcpRunner.ts index 0d45a5ed1..26d9d40a5 100644 --- a/packages/cli/tests/support/followUpQueueAcpRunner.ts +++ b/packages/cli/tests/support/followUpQueueAcpRunner.ts @@ -3,6 +3,7 @@ import { access, writeFile } from 'node:fs/promises'; import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { createTuiTaskAttentionSecretScanner } from './tuiTaskAttentionPtyDriver.js'; const CREDENTIAL_ENV_NAME = @@ -46,19 +47,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs: number -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function queueMetadata( client: ChildBackedRecordingAcpClient, sessionId: string diff --git a/packages/cli/tests/support/followUpQueuePtyRunner.ts b/packages/cli/tests/support/followUpQueuePtyRunner.ts index 53c1522d3..e40517bb1 100644 --- a/packages/cli/tests/support/followUpQueuePtyRunner.ts +++ b/packages/cli/tests/support/followUpQueuePtyRunner.ts @@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { stripVTControlCharacters } from 'node:util'; import { spawn } from 'bun-pty'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, projectForegroundBoundedPtyOutput, @@ -30,19 +31,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs: number -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 25)); - } - throw new Error(message); -} - function inboxText(content: unknown): string { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; diff --git a/packages/cli/tests/support/followUpQueueWebDriver.ts b/packages/cli/tests/support/followUpQueueWebDriver.ts index 2b62811e4..bdaef879a 100644 --- a/packages/cli/tests/support/followUpQueueWebDriver.ts +++ b/packages/cli/tests/support/followUpQueueWebDriver.ts @@ -5,7 +5,10 @@ import path from 'node:path'; import { chromium, type Page } from 'playwright'; import { SessionSchema } from '../../src/api/schemas.js'; import type { ProcessIdentity } from '../../src/utils/process/ProcessIdentity.js'; -import { reserveLoopbackPort as reservePort } from './asyncTestUtils.js'; +import { + reserveLoopbackPort as reservePort, + waitForCondition as waitFor, +} from './asyncTestUtils.js'; import { captureForegroundGuiLauncherIdentity, isExpectedBrowserRequestFailure, @@ -45,19 +48,6 @@ async function waitForHttp(origin: string, timeoutMs: number): Promise { throw new Error('Follow-up queue Web server did not become ready'); } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs: number -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function submit(page: Page, text: string): Promise { const composer = page.locator('textarea[data-blade-composer]'); await composer.fill(text); diff --git a/packages/cli/tests/support/foregroundBoundedOutputPtyRunner.ts b/packages/cli/tests/support/foregroundBoundedOutputPtyRunner.ts index 568229ef0..55fe2d7ea 100644 --- a/packages/cli/tests/support/foregroundBoundedOutputPtyRunner.ts +++ b/packages/cli/tests/support/foregroundBoundedOutputPtyRunner.ts @@ -1,4 +1,5 @@ import { spawn } from 'bun-pty'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, latchForegroundBoundedPtyMarkers, @@ -12,27 +13,6 @@ const required = (name: string): string => { return value; }; -function waitFor( - predicate: () => boolean, - message: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - startedAt >= timeoutMs) { - clearInterval(timer); - reject(new Error(message)); - } - }, 50); - }); -} - async function main(): Promise { const cliEntry = required('BLADE_BOUNDED_PTY_CLI_ENTRY'); const workspace = required('BLADE_BOUNDED_PTY_WORKSPACE'); diff --git a/packages/cli/tests/support/foregroundCommandHandoffAcpRunner.ts b/packages/cli/tests/support/foregroundCommandHandoffAcpRunner.ts index c0cfd95ae..61ba1dcac 100644 --- a/packages/cli/tests/support/foregroundCommandHandoffAcpRunner.ts +++ b/packages/cli/tests/support/foregroundCommandHandoffAcpRunner.ts @@ -4,7 +4,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; -import { waitForChildExit } from './asyncTestUtils.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; import { driveForegroundCommandHandoffFixture, type ForegroundCommandHandoffFixture, @@ -37,19 +37,6 @@ function childEnvironment(input: RunnerInput): NodeJS.ProcessEnv { }; } -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 90_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function run(input: RunnerInput) { const child = spawn(process.execPath, [input.cliEntry, '--acp'], { cwd: input.workspace, @@ -93,13 +80,17 @@ async function run(input: RunnerInput) { sessionId, fixture: input.fixture, waitForSurfaceHandoff: async (shellId) => { - await waitFor(() => { - const serialized = JSON.stringify(client.sessionUpdates); - return ( - serialized.includes(shellId) && - serialized.toLowerCase().includes('background') - ); - }, 'ACP did not project the foreground handoff result'); + await waitFor( + () => { + const serialized = JSON.stringify(client.sessionUpdates); + return ( + serialized.includes(shellId) && + serialized.toLowerCase().includes('background') + ); + }, + 'ACP did not project the foreground handoff result', + 90_000 + ); if (client.releaseCounts.size !== 0) { throw new Error('ACP terminal was released before host barrier completion'); } diff --git a/packages/cli/tests/support/foregroundCommandHandoffFixtureDriver.ts b/packages/cli/tests/support/foregroundCommandHandoffFixtureDriver.ts index fbe4d7ba5..786615871 100644 --- a/packages/cli/tests/support/foregroundCommandHandoffFixtureDriver.ts +++ b/packages/cli/tests/support/foregroundCommandHandoffFixtureDriver.ts @@ -7,6 +7,7 @@ import { findSessionTranscript, readSessionEvents, } from '../integration/real-api/sessionForkTrajectoryHarness.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { assertSplitPtyMarkerInstructionAtEnd, createSplitPtyMarkerInstruction, @@ -46,24 +47,6 @@ async function exists(filePath: string): Promise { } } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 90_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - function processAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -172,30 +155,34 @@ export async function driveForegroundCommandHandoffFixture(input: { } let shellId = ''; - await waitFor(() => { - const results = completedToolParts( - readEvents(input.storageRoot, input.sessionId), - 'Bash' - ); - if (results.length !== 1) return false; - const result = results[0]; - const output = result.output; - const metadata = result.metadata; - if (!isRecord(output) || !isRecord(metadata)) return false; - if ( - output.auto_backgrounded !== true || - output.background_reason !== 'foreground_budget' || - metadata.auto_backgrounded !== true || - metadata.background_reason !== 'foreground_budget' || - metadata.foreground_budget_ms !== 1_000 || - typeof output.shell_id !== 'string' || - output.shell_id !== metadata.shell_id - ) { - return false; - } - shellId = output.shell_id; - return true; - }, 'Durable Bash result did not publish foreground handoff metadata'); + await waitFor( + () => { + const results = completedToolParts( + readEvents(input.storageRoot, input.sessionId), + 'Bash' + ); + if (results.length !== 1) return false; + const result = results[0]; + const output = result.output; + const metadata = result.metadata; + if (!isRecord(output) || !isRecord(metadata)) return false; + if ( + output.auto_backgrounded !== true || + output.background_reason !== 'foreground_budget' || + metadata.auto_backgrounded !== true || + metadata.background_reason !== 'foreground_budget' || + metadata.foreground_budget_ms !== 1_000 || + typeof output.shell_id !== 'string' || + output.shell_id !== metadata.shell_id + ) { + return false; + } + shellId = output.shell_id; + return true; + }, + 'Durable Bash result did not publish foreground handoff metadata', + 90_000 + ); if ( !(await exists(activeFile)) || @@ -215,13 +202,17 @@ export async function driveForegroundCommandHandoffFixture(input: { } await input.waitForSurfaceHandoff(shellId); - await waitFor(() => { - const reads = completedToolParts( - readEvents(input.storageRoot, input.sessionId), - 'Read' - ); - return reads.length === 1; - }, 'The Agent did not complete independent Read while the shell was running'); + await waitFor( + () => { + const reads = completedToolParts( + readEvents(input.storageRoot, input.sessionId), + 'Read' + ); + return reads.length === 1; + }, + 'The Agent did not complete independent Read while the shell was running', + 90_000 + ); if ( !(await exists(activeFile)) || (await exists(completedFile)) || @@ -233,18 +224,26 @@ export async function driveForegroundCommandHandoffFixture(input: { await writeFile(releaseFile, 'release'); await waitFor( async () => (await exists(completedFile)) && !(await exists(activeFile)), - 'Foreground handoff child did not complete after host release' + 'Foreground handoff child did not complete after host release', + 90_000 ); await waitFor( () => !processAlive(pid), - 'Foreground handoff child remained after terminal completion' + 'Foreground handoff child remained after terminal completion', + 90_000 ); - await waitFor(() => { - const events = readEvents(input.storageRoot, input.sessionId); - const outputs = completedToolParts(events, 'TaskOutput'); - return outputs.length === 1 && finalAssistantText(events) === input.fixture.marker; - }, 'Foreground handoff Session did not persist its exact final authority'); + await waitFor( + () => { + const events = readEvents(input.storageRoot, input.sessionId); + const outputs = completedToolParts(events, 'TaskOutput'); + return ( + outputs.length === 1 && finalAssistantText(events) === input.fixture.marker + ); + }, + 'Foreground handoff Session did not persist its exact final authority', + 90_000 + ); const events = readEvents(input.storageRoot, input.sessionId); const trace = extractDurableToolTrace(events); diff --git a/packages/cli/tests/support/foregroundCommandHandoffPtyRunner.ts b/packages/cli/tests/support/foregroundCommandHandoffPtyRunner.ts index 83e20cbc9..d900e6c3f 100644 --- a/packages/cli/tests/support/foregroundCommandHandoffPtyRunner.ts +++ b/packages/cli/tests/support/foregroundCommandHandoffPtyRunner.ts @@ -1,4 +1,5 @@ import { spawn } from 'bun-pty'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { ArmedPtyMarkerLatch, appendBoundedPtyEvidence, @@ -27,19 +28,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 90_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function main(): Promise { const input = loadInput(); if (input.fixture.prompt.includes(input.fixture.marker)) { @@ -116,13 +104,15 @@ async function main(): Promise { waitForSurfaceHandoff: async (shellId) => { await waitFor( () => output.includes(shellId) && output.toLowerCase().includes('background'), - 'Raw PTY did not render foreground handoff result' + 'Raw PTY did not render foreground handoff result', + 90_000 ); }, }); await waitFor( () => finalMarkerLatch.seen, - 'Raw PTY did not render foreground handoff marker' + 'Raw PTY did not render foreground handoff marker', + 90_000 ); if (secretLatch.seen) { throw new Error('Raw PTY handoff capture contained provider credentials'); diff --git a/packages/cli/tests/support/foregroundProviderRecoveryPtyRunner.ts b/packages/cli/tests/support/foregroundProviderRecoveryPtyRunner.ts index 3f3c93db7..3f6b2450e 100644 --- a/packages/cli/tests/support/foregroundProviderRecoveryPtyRunner.ts +++ b/packages/cli/tests/support/foregroundProviderRecoveryPtyRunner.ts @@ -5,6 +5,7 @@ import { findSessionTranscript, readSessionEvents, } from '../integration/real-api/sessionForkTrajectoryHarness.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { ArmedPtyMarkerLatch, appendBoundedPtyEvidence, @@ -36,19 +37,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 120_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function signalTerminalTree( pid: number, signal: NodeJS.Signals, diff --git a/packages/cli/tests/support/goalExecutionHostFailureAcpRunner.ts b/packages/cli/tests/support/goalExecutionHostFailureAcpRunner.ts index 23ef03d76..354d73a30 100644 --- a/packages/cli/tests/support/goalExecutionHostFailureAcpRunner.ts +++ b/packages/cli/tests/support/goalExecutionHostFailureAcpRunner.ts @@ -2,7 +2,7 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; -import { waitForCondition as waitFor } from './asyncTestUtils.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -20,36 +20,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -function waitForChildExit( - child: ChildProcess, - timeoutMs = 10_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Goal host failure ACP child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - function goalMetadata(client: ChildBackedRecordingAcpClient) { return client.sessionUpdates.flatMap((notification) => { const metadata = notification.update._meta?.['blade/goal']; @@ -116,7 +86,7 @@ async function main(): Promise { } child.kill('SIGTERM'); - const exit = await waitForChildExit(child); + const exit = await waitForChildExit(child, 10_000); await connection.closed.catch(() => undefined); if (exit.signal || exit.code !== 0) { throw new Error( diff --git a/packages/cli/tests/support/goalFinalizationPtyRunner.ts b/packages/cli/tests/support/goalFinalizationPtyRunner.ts index d71f39fec..ede2885fd 100644 --- a/packages/cli/tests/support/goalFinalizationPtyRunner.ts +++ b/packages/cli/tests/support/goalFinalizationPtyRunner.ts @@ -1,6 +1,7 @@ import { access } from 'node:fs/promises'; import { spawn } from 'bun-pty'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, latchPtyMarker, @@ -15,27 +16,6 @@ function required(name: string): string { return value; } -function waitFor( - predicate: () => boolean, - message: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - startedAt >= timeoutMs) { - clearInterval(timer); - reject(new Error(message)); - } - }, 50); - }); -} - async function waitForInboxRemoval( workspace: string, sessionId: string, diff --git a/packages/cli/tests/support/goalTurnLineageAcpRunner.ts b/packages/cli/tests/support/goalTurnLineageAcpRunner.ts index 854449874..1851797c6 100644 --- a/packages/cli/tests/support/goalTurnLineageAcpRunner.ts +++ b/packages/cli/tests/support/goalTurnLineageAcpRunner.ts @@ -2,6 +2,7 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { cliEntry: string; @@ -19,49 +20,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 220_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - -function waitForChildExit( - child: ChildProcess, - timeoutMs = 10_000 -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Goal turn lineage ACP child did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - function metadata( client: ChildBackedRecordingAcpClient, key: 'blade/goal' | 'blade/goalContinuation' @@ -124,7 +82,8 @@ async function main(): Promise { ) || child.exitCode !== null || child.signalCode !== null, - 'ACP did not observe a terminal Goal lineage' + 'ACP did not observe a terminal Goal lineage', + 220_000 ); const continuations = metadata(client, 'blade/goalContinuation'); const goals = metadata(client, 'blade/goal'); @@ -144,7 +103,7 @@ async function main(): Promise { } child.kill('SIGTERM'); - const exit = await waitForChildExit(child); + const exit = await waitForChildExit(child, 10_000); await connection.closed.catch(() => undefined); if (exit.signal || exit.code !== 0) { throw new Error( diff --git a/packages/cli/tests/support/goalTurnLineagePtyRunner.ts b/packages/cli/tests/support/goalTurnLineagePtyRunner.ts index ff814d31f..e15694b72 100644 --- a/packages/cli/tests/support/goalTurnLineagePtyRunner.ts +++ b/packages/cli/tests/support/goalTurnLineagePtyRunner.ts @@ -1,5 +1,6 @@ import { spawn } from 'bun-pty'; import { GoalStore } from '../../src/goals/GoalStore.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, latchPtyMarker, @@ -24,19 +25,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 220_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function signalTree(pid: number, signal: NodeJS.Signals, fallback: () => void): void { try { process.kill(-pid, signal); @@ -105,15 +93,21 @@ async function main(): Promise { try { await waitFor( () => output.includes(handshake.marker), - 'Goal turn lineage TUI composer did not become ready' + 'Goal turn lineage TUI composer did not become ready', + 220_000 + ); + await waitFor( + () => blocked, + 'Goal turn lineage TUI did not reach blocked state', + 220_000 ); - await waitFor(() => blocked, 'Goal turn lineage TUI did not reach blocked state'); const goal = await new GoalStore(input.workspace, input.sessionId).get(); if (!goal?.turnLineage) throw new Error('Goal turn lineage did not persist'); await writeBracketedPaste(terminal, '/goal status'); await waitFor( () => output.includes('/goal status'), - 'Goal status command did not reach the TUI composer' + 'Goal status command did not reach the TUI composer', + 220_000 ); terminal.write('\r'); const expected = [ @@ -121,11 +115,15 @@ async function main(): Promise { `Current turn: ${goal.turnLineage.currentTurnId}`, `Parent turn: ${goal.turnLineage.parentTurnId ?? '?'}`, ]; - await waitFor(() => { - const visible = projectForegroundBoundedPtyOutput(output); - fullLineage = expected.every((marker) => visible.includes(marker)); - return fullLineage; - }, 'Goal turn lineage TUI did not render full status ancestry'); + await waitFor( + () => { + const visible = projectForegroundBoundedPtyOutput(output); + fullLineage = expected.every((marker) => visible.includes(marker)); + return fullLineage; + }, + 'Goal turn lineage TUI did not render full status ancestry', + 220_000 + ); if (output.includes(input.secret)) { throw new Error('Goal turn lineage TUI leaked a credential'); } diff --git a/packages/cli/tests/support/gracefulShutdownPtyRunner.ts b/packages/cli/tests/support/gracefulShutdownPtyRunner.ts index bbf8604af..a41dab065 100644 --- a/packages/cli/tests/support/gracefulShutdownPtyRunner.ts +++ b/packages/cli/tests/support/gracefulShutdownPtyRunner.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import { spawn } from 'bun-pty'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence } from './foregroundBoundedOutputPtyDriver.js'; import { createTuiPtyComposerReadyHandshake, writeBracketedPaste } from './ptyInput.js'; @@ -21,25 +22,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -function waitFor( - predicate: () => boolean, - message: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const deadline = Date.now() + timeoutMs; - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - } else if (Date.now() >= deadline) { - clearInterval(timer); - reject(new Error(message)); - } - }, 50); - }); -} - function signalTerminalTree( pid: number, signal: NodeJS.Signals, diff --git a/packages/cli/tests/support/launch-session-surface-gui.ts b/packages/cli/tests/support/launch-session-surface-gui.ts index 902a3a1a7..18e8da805 100644 --- a/packages/cli/tests/support/launch-session-surface-gui.ts +++ b/packages/cli/tests/support/launch-session-surface-gui.ts @@ -10,6 +10,7 @@ import { type PairedAcpFixtureSessionRef, type PairedAcpProductionFixture, } from './acp/remoteFilesystemQualification.js'; +import { waitForChildExit } from './asyncTestUtils.js'; const CLI_ENTRY = path.resolve(import.meta.dirname, '../../dist/blade.js'); const OUTPUT_TAIL_LIMIT = 16_384; @@ -77,36 +78,6 @@ export async function waitForSessionSurfaceCondition( throw new Error(message, { cause }); } -function waitForChildExit( - child: ChildProcess, - timeoutMs: number -): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Session surface GUI server did not exit')); - }, timeoutMs); - const cleanup = () => { - clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - resolve({ code, signal }); - }; - child.once('error', onError); - child.once('exit', onExit); - }); -} - async function stopChild(child: ChildProcess): Promise { if (child.exitCode !== null || child.signalCode !== null) return; child.kill('SIGTERM'); diff --git a/packages/cli/tests/support/memoryConsolidationPtyRunner.ts b/packages/cli/tests/support/memoryConsolidationPtyRunner.ts index eeb78394b..a2dd0d7ad 100644 --- a/packages/cli/tests/support/memoryConsolidationPtyRunner.ts +++ b/packages/cli/tests/support/memoryConsolidationPtyRunner.ts @@ -7,11 +7,12 @@ import { findSessionTranscript, readSessionEvents, } from '../integration/real-api/sessionForkTrajectoryHarness.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { ArmedPtyMarkerLatch, appendBoundedPtyEvidence, - projectForegroundBoundedPtyOutput, latestCompleteStandardPtyFrame, + projectForegroundBoundedPtyOutput, waitForPtyExit, } from './foregroundBoundedOutputPtyDriver.js'; import { createTuiPtyComposerReadyHandshake, writeBracketedPaste } from './ptyInput.js'; @@ -42,19 +43,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs = 90_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function signalTerminalTree( pid: number, signal: NodeJS.Signals, @@ -160,7 +148,8 @@ async function main(): Promise { await submit(`Reply with exactly ${warmupMarker}. Do not use tools.`); await waitFor( () => finalAssistantText(readSessionEvents(transcript)) === warmupMarker, - 'Warmup response was not committed' + 'Warmup response was not committed', + 90_000 ); const contextValue = () => latestCompleteStandardPtyFrame(output, /Cache[^\r\n]*\r?\n?$/)?.match( @@ -168,7 +157,8 @@ async function main(): Promise { )?.[1]; await waitFor( () => contextValue() !== undefined && contextValue() !== '100', - 'Warmup did not report nonzero context' + 'Warmup did not report nonzero context', + 90_000 ); const before = contextValue(); await submit(input.prompt); @@ -178,7 +168,8 @@ async function main(): Promise { () => true, () => false ), - 'Automatic summary did not reach cancellation barrier' + 'Automatic summary did not reach cancellation barrier', + 90_000 ); terminal.write('\u001b'); await waitFor( @@ -257,7 +248,8 @@ async function main(): Promise { () => true, () => false ), - 'Manual compaction Provider did not start' + 'Manual compaction Provider did not start', + 90_000 ); const started = Date.now(); terminal.write('\u001b'); @@ -317,7 +309,8 @@ async function main(): Promise { finalMarker.seen && memoryNoticeSeen && plainOutput.lastIndexOf('yolo mode on') > plainOutput.lastIndexOf(input.marker), - 'Memory consolidation TUI did not complete with its memory notice' + 'Memory consolidation TUI did not complete with its memory notice', + 90_000 ); await waitFor( () => @@ -414,7 +407,8 @@ async function main(): Promise { stripVTControlCharacters(discoveryOutput).lastIndexOf( input.discoveryMarker ), - 'Memory consolidation TUI did not discover the new memory index' + 'Memory consolidation TUI did not discover the new memory index', + 90_000 ); await waitFor( () => { diff --git a/packages/cli/tests/support/promptCacheStatusPtyRunner.ts b/packages/cli/tests/support/promptCacheStatusPtyRunner.ts index 33424839e..fe7367eab 100644 --- a/packages/cli/tests/support/promptCacheStatusPtyRunner.ts +++ b/packages/cli/tests/support/promptCacheStatusPtyRunner.ts @@ -1,5 +1,6 @@ import { stripVTControlCharacters } from 'node:util'; import { spawn } from 'bun-pty'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { latchPtyMarker } from './foregroundBoundedOutputPtyDriver.js'; import { createTuiPtyEnvironment } from './ptyInput.js'; @@ -9,27 +10,6 @@ const required = (name: string): string => { return value; }; -function waitFor( - predicate: () => boolean, - message: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - startedAt >= timeoutMs) { - clearInterval(timer); - reject(new Error(message)); - } - }, 50); - }); -} - async function main(): Promise { const cliEntry = required('BLADE_CACHE_PTY_CLI_ENTRY'); const workspace = required('BLADE_CACHE_PTY_WORKSPACE'); diff --git a/packages/cli/tests/support/rootTurnAutoResumePtyRunner.ts b/packages/cli/tests/support/rootTurnAutoResumePtyRunner.ts index 789144976..7885492ef 100644 --- a/packages/cli/tests/support/rootTurnAutoResumePtyRunner.ts +++ b/packages/cli/tests/support/rootTurnAutoResumePtyRunner.ts @@ -1,6 +1,7 @@ import { stripVTControlCharacters } from 'node:util'; import { spawn } from 'bun-pty'; import { PersistentStore } from '../../src/context/storage/PersistentStore.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, latchPtyMarker, @@ -18,27 +19,6 @@ function required(name: string): string { return value; } -function waitFor( - predicate: () => boolean, - message: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - startedAt >= timeoutMs) { - clearInterval(timer); - reject(new Error(message)); - } - }, 50); - }); -} - function signalTerminalTree( pid: number, signal: NodeJS.Signals, diff --git a/packages/cli/tests/support/sessionRuntimeResidencyPtyRunner.ts b/packages/cli/tests/support/sessionRuntimeResidencyPtyRunner.ts index 4e5a2f8a7..bc192598b 100644 --- a/packages/cli/tests/support/sessionRuntimeResidencyPtyRunner.ts +++ b/packages/cli/tests/support/sessionRuntimeResidencyPtyRunner.ts @@ -6,6 +6,7 @@ import { processIdentityMatches, } from '../../src/utils/process/ProcessIdentity.js'; import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { createTuiPtyComposerReadyHandshake, writeBracketedPaste } from './ptyInput.js'; interface RunnerInput { @@ -37,19 +38,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs: number -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function signalTerminalTree(terminal: PtyProcess, signal: NodeJS.Signals): void { try { process.kill(-terminal.pid, signal); diff --git a/packages/cli/tests/support/sideConversationCancellationRunner.ts b/packages/cli/tests/support/sideConversationCancellationRunner.ts index 87a0dc0dd..e0a85efed 100644 --- a/packages/cli/tests/support/sideConversationCancellationRunner.ts +++ b/packages/cli/tests/support/sideConversationCancellationRunner.ts @@ -16,6 +16,7 @@ import { readSessionEvents, } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildProcessRecordingAcpClient } from './acp/ChildProcessRecordingAcpClient.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { latestCompleteStandardPtyFrame, waitForPtyExit, @@ -53,19 +54,6 @@ const Input = Type.Object({ type Input = Static; -async function waitFor( - check: () => boolean | Promise, - label: string, - timeoutMs = 30_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await check()) return; - await new Promise((resolve) => setTimeout(resolve, 25)); - } - throw new Error(label); -} - function environment(input: Input) { return { ...createTuiTaskAttentionRunnerEnvironment(process.env, { @@ -138,7 +126,11 @@ async function runPty(input: Input) { const expectedPaste = projectedPaste ? `${text.length} chars, ${text.split('\n').length} lines:` : text.split('\n')[0]!.slice(0, 40); - await waitFor(() => plain.includes(expectedPaste), 'PTY paste was not rendered'); + await waitFor( + () => plain.includes(expectedPaste), + 'PTY paste was not rendered', + 30_000 + ); terminal.write('\r'); }; try { @@ -157,7 +149,8 @@ async function runPty(input: Input) { await writeFile(input.holdFile, 'hold'); await waitFor( async () => (await readFile(input.traceFile, 'utf8')).includes('catalog_held'), - 'MCP refresh did not hold' + 'MCP refresh did not hold', + 30_000 ); const before = await readFile(transcript); plain = ''; @@ -168,7 +161,8 @@ async function runPty(input: Input) { ); await waitFor( () => plain.includes('Answering...'), - 'TUI side question did not start' + 'TUI side question did not start', + 30_000 ); if (input.dismissWhileMainRunning) { await writeFile(input.releaseFile, 'release'); @@ -184,7 +178,8 @@ async function runPty(input: Input) { await writeBracketedPaste(terminal, 'ACTIVE_MAIN_DRAFT'); await waitFor( () => stripVTControlCharacters(output).includes('ACTIVE_MAIN_DRAFT'), - 'Active main draft did not render' + 'Active main draft did not render', + 30_000 ); } plain = ''; @@ -253,20 +248,23 @@ async function runPty(input: Input) { terminal.write('\u0015'); await waitFor( () => stripVTControlCharacters(output).includes('输入命令...'), - 'Active draft did not clear after main cancellation' + 'Active draft did not clear after main cancellation', + 30_000 ); } await writeFile(input.releaseFile, 'release'); await waitFor( async () => (await readFile(input.traceFile, 'utf8')).includes('catalog_released'), - 'Shared MCP refresh did not resume' + 'Shared MCP refresh did not resume', + 30_000 ); const contextPercent = () => [...stripVTControlCharacters(output).matchAll(/(\d+)%\s*·\s*Cache/g)].at(-1)?.[1]; await waitFor( () => contextPercent() !== undefined, - 'TUI context meter was not rendered' + 'TUI context meter was not rendered', + 30_000 ); const mainContextPercent = contextPercent(); plain = ''; @@ -295,15 +293,19 @@ async function runPty(input: Input) { return start === undefined ? undefined : rendered.slice(start).trim(); }; const assertSideLayout = async (rows: number, marker = input.marker) => { - await waitFor(() => { - const frame = visibleSideFrame(); - return Boolean( - frame?.includes(marker) && - frame.includes('输入命令...') && - /\d+%\s*·\s*Cache/.test(frame) && - !frame.includes('Answering...') - ); - }, 'Long side question displaced the answer or main context meter'); + await waitFor( + () => { + const frame = visibleSideFrame(); + return Boolean( + frame?.includes(marker) && + frame.includes('输入命令...') && + /\d+%\s*·\s*Cache/.test(frame) && + !frame.includes('Answering...') + ); + }, + 'Long side question displaced the answer or main context meter', + 30_000 + ); const frame = visibleSideFrame(); if (!frame || frame.split('\n').length > rows) { throw new Error('Long side question exceeded the terminal height'); @@ -335,13 +337,14 @@ async function runPty(input: Input) { let viewportRows = 48; const draft = 'MAIN_DRAFT_PRESERVED'; await writeBracketedPaste(terminal, draft); - await waitFor(() => plain.includes(draft), 'Main draft did not render'); + await waitFor(() => plain.includes(draft), 'Main draft did not render', 30_000); const page = async (key: string) => { output = ''; terminal.write(key); await waitFor( () => Boolean(visibleSideFrame()?.includes('PgUp/PgDn')), - 'Side page did not render' + 'Side page did not render', + 30_000 ); const frame = visibleSideFrame(); if (!frame?.includes(draft)) throw new Error('Paging changed the main draft'); @@ -364,7 +367,8 @@ async function runPty(input: Input) { terminal.resize(100, 36); await waitFor( () => Boolean(visibleSideFrame()?.includes('PgUp/PgDn')), - 'Resized side page did not render' + 'Resized side page did not render', + 30_000 ); for ( let index = 0; @@ -397,7 +401,8 @@ async function runPty(input: Input) { terminal.write('\u0015'); await waitFor( () => Boolean(visibleSideFrame()?.includes('输入命令...')), - 'Main draft did not clear' + 'Main draft did not clear', + 30_000 ); await assertSideLayout(36); } else { @@ -410,22 +415,27 @@ async function runPty(input: Input) { await writeBracketedPaste(terminal, dismissalDraft); await waitFor( () => stripVTControlCharacters(output).includes(dismissalDraft), - 'Dismissal draft did not render' + 'Dismissal draft did not render', + 30_000 ); output = ''; terminal.write('\u001b'); - await waitFor(() => { - const rendered = latestCompleteStandardPtyFrame( - output, - /\n[^\r\n]*\d+%\s*·\s*Cache[^\r\n]*\r?\n$/ - ); - return Boolean( - rendered?.includes(dismissalDraft) && - /\d+%\s*·\s*Cache/.test(rendered) && - !rendered.includes('BTW') && - !rendered.includes('PgUp/PgDn') - ); - }, 'Escape did not dismiss the completed side panel while preserving the draft'); + await waitFor( + () => { + const rendered = latestCompleteStandardPtyFrame( + output, + /\n[^\r\n]*\d+%\s*·\s*Cache[^\r\n]*\r?\n$/ + ); + return Boolean( + rendered?.includes(dismissalDraft) && + /\d+%\s*·\s*Cache/.test(rendered) && + !rendered.includes('BTW') && + !rendered.includes('PgUp/PgDn') + ); + }, + 'Escape did not dismiss the completed side panel while preserving the draft', + 30_000 + ); if (contextPercent() !== mainContextPercent) { throw new Error( `Side usage replaced main context: ${mainContextPercent}% -> ${contextPercent()}%` @@ -582,7 +592,8 @@ async function runAcp(input: Input) { await writeFile(input.holdFile, 'hold'); await waitFor( async () => (await readFile(input.traceFile, 'utf8')).includes('catalog_held'), - 'ACP MCP refresh did not hold' + 'ACP MCP refresh did not hold', + 30_000 ); client.sessionUpdates.length = 0; output = ''; @@ -604,7 +615,8 @@ async function runAcp(input: Input) { ); await waitFor( () => output.includes(`Executing slash command: ${question}`), - 'ACP did not accept side question' + 'ACP did not accept side question', + 30_000 ); const stoppedAt = Date.now(); await connection.cancel({ sessionId: session.sessionId }); @@ -626,7 +638,8 @@ async function runAcp(input: Input) { await waitFor( async () => (await readFile(input.traceFile, 'utf8')).includes('catalog_released'), - 'ACP catalog did not resume' + 'ACP catalog did not resume', + 30_000 ); client.sessionUpdates.length = 0; const followupQuestion = input.followupQuestion; diff --git a/packages/cli/tests/support/subagentResultAdoptionPtyRunner.ts b/packages/cli/tests/support/subagentResultAdoptionPtyRunner.ts index 639538b68..490fcd4f8 100644 --- a/packages/cli/tests/support/subagentResultAdoptionPtyRunner.ts +++ b/packages/cli/tests/support/subagentResultAdoptionPtyRunner.ts @@ -1,6 +1,7 @@ import { access } from 'node:fs/promises'; import { spawn } from 'bun-pty'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, latchPtyMarker, @@ -14,27 +15,6 @@ function required(name: string): string { return value; } -function waitFor( - predicate: () => boolean, - message: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - startedAt >= timeoutMs) { - clearInterval(timer); - reject(new Error(message)); - } - }, 50); - }); -} - async function waitForInboxRemoval( workspace: string, sessionId: string, diff --git a/packages/cli/tests/support/toolAdmissionAcpRunner.ts b/packages/cli/tests/support/toolAdmissionAcpRunner.ts index 606bf8d9c..a6f17db16 100644 --- a/packages/cli/tests/support/toolAdmissionAcpRunner.ts +++ b/packages/cli/tests/support/toolAdmissionAcpRunner.ts @@ -5,7 +5,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; -import { waitForChildExit } from './asyncTestUtils.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; import { driveToolAdmissionFixture, TOOL_ADMISSION_CALL_IDS, @@ -40,19 +40,6 @@ function childEnvironment(input: RunnerInput): NodeJS.ProcessEnv { }; } -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 90_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function releaseAll(stateDir: string): Promise { const releaseDir = path.join(stateDir, 'release'); await mkdir(releaseDir, { recursive: true }); @@ -113,7 +100,8 @@ async function run(input: RunnerInput) { ).length - 1 >= 2, - 'ACP did not project two queued tool calls' + 'ACP did not project two queued tool calls', + 90_000 ), }); await Promise.race([ diff --git a/packages/cli/tests/support/toolAdmissionFixtureDriver.ts b/packages/cli/tests/support/toolAdmissionFixtureDriver.ts index 6fbf7f82f..009ee01ae 100644 --- a/packages/cli/tests/support/toolAdmissionFixtureDriver.ts +++ b/packages/cli/tests/support/toolAdmissionFixtureDriver.ts @@ -5,6 +5,7 @@ import { findSessionTranscript, readSessionEvents, } from '../integration/real-api/sessionForkTrajectoryHarness.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; export const TOOL_ADMISSION_CALL_IDS = [ 'call-1', @@ -22,24 +23,6 @@ async function directoryEntries(directory: string): Promise { } } -async function waitFor( - predicate: () => Promise, - message: string, - timeoutMs = 90_000 -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - if (await predicate()) return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message, { cause: lastError }); -} - function equalEntries(actual: readonly string[], expected: readonly string[]): boolean { return ( actual.length === expected.length && @@ -116,13 +99,15 @@ export async function driveToolAdmissionFixture(input: { }): Promise { await waitFor( async () => countCanonicalBashCalls(input.storageRoot, input.sessionId) === 4, - 'The Provider did not commit four canonical Bash calls' + 'The Provider did not commit four canonical Bash calls', + 90_000 ); await input.waitForQueuedEvidence(); await waitFor( async () => (await directoryEntries(path.join(input.stateDir, 'started'))).length >= 2, - 'The first two admitted Bash calls did not start' + 'The first two admitted Bash calls did not start', + 90_000 ); const firstWave = await directoryEntries(path.join(input.stateDir, 'started')); if (firstWave.length !== 2) { @@ -134,7 +119,8 @@ export async function driveToolAdmissionFixture(input: { await waitFor( async () => (await directoryEntries(path.join(input.stateDir, 'started'))).length >= 3, - 'Releasing the first call did not admit exactly one successor' + 'Releasing the first call did not admit exactly one successor', + 90_000 ); const thirdWave = await directoryEntries(path.join(input.stateDir, 'started')); const thirdCall = thirdWave.find((callId) => !firstWave.includes(callId)); @@ -149,7 +135,8 @@ export async function driveToolAdmissionFixture(input: { await waitFor( async () => (await directoryEntries(path.join(input.stateDir, 'started'))).length >= 4, - 'Releasing the second call did not admit exactly one successor' + 'Releasing the second call did not admit exactly one successor', + 90_000 ); const fourthWave = await directoryEntries(path.join(input.stateDir, 'started')); const remainingCalls = fourthWave.filter((callId) => !firstWave.includes(callId)); @@ -159,7 +146,8 @@ export async function driveToolAdmissionFixture(input: { await waitFor( async () => (await directoryEntries(path.join(input.stateDir, 'completed'))).length === 4, - 'The admitted Bash calls did not all complete' + 'The admitted Bash calls did not all complete', + 90_000 ); await assertState( input.stateDir, @@ -174,21 +162,25 @@ export async function waitForToolAdmissionSessionCompletion( sessionId: string, finalMarker: string ): Promise { - await waitFor(async () => { - const events = readSessionEvents(findSessionTranscript(storageRoot, sessionId)); - const results = events.filter( - (event) => - event.type === 'part_created' && - event.data.partType === 'tool_result' && - event.data.payload !== null && - typeof event.data.payload === 'object' && - !Array.isArray(event.data.payload) && - event.data.payload.toolName === 'Bash' - ); - return ( - results.length === 4 && - events.filter((event) => event.type === 'turn_completed').length === 1 && - finalAssistantText(events) === finalMarker - ); - }, 'Tool admission Session did not persist four results, one completed turn, and the final marker'); + await waitFor( + async () => { + const events = readSessionEvents(findSessionTranscript(storageRoot, sessionId)); + const results = events.filter( + (event) => + event.type === 'part_created' && + event.data.partType === 'tool_result' && + event.data.payload !== null && + typeof event.data.payload === 'object' && + !Array.isArray(event.data.payload) && + event.data.payload.toolName === 'Bash' + ); + return ( + results.length === 4 && + events.filter((event) => event.type === 'turn_completed').length === 1 && + finalAssistantText(events) === finalMarker + ); + }, + 'Tool admission Session did not persist four results, one completed turn, and the final marker', + 90_000 + ); } diff --git a/packages/cli/tests/support/toolAdmissionPtyRunner.ts b/packages/cli/tests/support/toolAdmissionPtyRunner.ts index b9fc1c917..b6296aa8a 100644 --- a/packages/cli/tests/support/toolAdmissionPtyRunner.ts +++ b/packages/cli/tests/support/toolAdmissionPtyRunner.ts @@ -1,6 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { spawn } from 'bun-pty'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { ArmedPtyMarkerLatch, appendBoundedPtyEvidence, @@ -35,19 +36,6 @@ function countOccurrences(value: string, needle: string): number { return value.split(needle).length - 1; } -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 90_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - async function releaseAll(stateDir: string): Promise { const releaseDir = path.join(stateDir, 'release'); await mkdir(releaseDir, { recursive: true }); @@ -132,7 +120,8 @@ async function main(): Promise { waitForQueuedEvidence: () => waitFor( () => countOccurrences(output, 'Waiting for tool execution capacity') >= 2, - 'Raw PTY did not project two queued tool calls' + 'Raw PTY did not project two queued tool calls', + 90_000 ), }); await waitForToolAdmissionSessionCompletion( @@ -142,7 +131,8 @@ async function main(): Promise { ); await waitFor( () => finalMarkerLatch.seen, - 'Raw PTY did not render the final admission marker' + 'Raw PTY did not render the final admission marker', + 90_000 ); if (secretLatch.seen) { throw new Error('Raw PTY admission capture contained provider credentials'); diff --git a/packages/cli/tests/support/tuiPtyRunner.ts b/packages/cli/tests/support/tuiPtyRunner.ts index 2acd1fe02..bd612c379 100644 --- a/packages/cli/tests/support/tuiPtyRunner.ts +++ b/packages/cli/tests/support/tuiPtyRunner.ts @@ -1,4 +1,5 @@ import { spawn } from 'bun-pty'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, latchPtyMarker, @@ -17,27 +18,6 @@ const prompt = required('BLADE_TUI_TEST_PROMPT'); const expected = required('BLADE_TUI_TEST_EXPECTED'); const sessionId = required('BLADE_TUI_TEST_SESSION_ID'); -function waitFor( - predicate: () => boolean, - description: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - startedAt >= timeoutMs) { - clearInterval(timer); - reject(new Error(`Timed out waiting for TUI marker: ${description}`)); - } - }, 50); - }); -} - const handshake = createTuiPtyComposerReadyHandshake(); const terminal = spawn( '/usr/bin/env', diff --git a/packages/cli/tests/support/tuiTaskAttentionPtyRunner.ts b/packages/cli/tests/support/tuiTaskAttentionPtyRunner.ts index 4f402137d..a12cb8eaf 100644 --- a/packages/cli/tests/support/tuiTaskAttentionPtyRunner.ts +++ b/packages/cli/tests/support/tuiTaskAttentionPtyRunner.ts @@ -2,6 +2,7 @@ import { access, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import { spawn } from 'bun-pty'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { ArmedPtyMarkerLatch, appendBoundedPtyEvidence, @@ -95,19 +96,6 @@ function loadInput(): RunnerInput { }; } -async function waitFor( - predicate: () => boolean | Promise, - message: string, - timeoutMs: number -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function signalTerminalTree( pid: number, signal: NodeJS.Signals, diff --git a/packages/cli/tests/support/turnActivityAcpRunner.ts b/packages/cli/tests/support/turnActivityAcpRunner.ts index c85656cad..c297f8409 100644 --- a/packages/cli/tests/support/turnActivityAcpRunner.ts +++ b/packages/cli/tests/support/turnActivityAcpRunner.ts @@ -4,7 +4,7 @@ import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; import type { TurnActivityProjection } from '../../src/api/turnActivitySchemas.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; -import { waitForChildExit } from './asyncTestUtils.js'; +import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; import { createTuiTaskAttentionRunnerEnvironment } from './tuiTaskAttentionPtyDriver.js'; interface RunnerInput { @@ -31,19 +31,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 120_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function activityProjections( client: ChildBackedRecordingAcpClient ): TurnActivityProjection[] { @@ -268,7 +255,8 @@ async function run(input: RunnerInput) { activity.snapshot?.phase === 'executing_tools' && activity.snapshot.activeTools.some((tool) => tool.name === 'Bash') ), - 'ACP did not project active Bash before release' + 'ACP did not project active Bash before release', + 120_000 ); if (client instanceof CreationCancellationClient) { await waitFor( diff --git a/packages/cli/tests/support/turnActivityPtyRunner.ts b/packages/cli/tests/support/turnActivityPtyRunner.ts index 10d57d85e..bc49bd967 100644 --- a/packages/cli/tests/support/turnActivityPtyRunner.ts +++ b/packages/cli/tests/support/turnActivityPtyRunner.ts @@ -1,6 +1,12 @@ import { writeFile } from 'node:fs/promises'; import { stripVTControlCharacters } from 'node:util'; import { spawn } from 'bun-pty'; +import { + findSessionTranscript, + inspectFinalAssistantText, + readSessionEvents, +} from '../integration/real-api/sessionForkTrajectoryHarness.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { ArmedPtyMarkerLatch, appendBoundedPtyEvidence, @@ -9,11 +15,6 @@ import { waitForPtyExit, waitForPtyFinalization, } from './foregroundBoundedOutputPtyDriver.js'; -import { - findSessionTranscript, - inspectFinalAssistantText, - readSessionEvents, -} from '../integration/real-api/sessionForkTrajectoryHarness.js'; import { createTuiPtyComposerReadyHandshake, writeBracketedPaste } from './ptyInput.js'; import { createTuiTaskAttentionRunnerEnvironment } from './tuiTaskAttentionPtyDriver.js'; @@ -42,19 +43,6 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } -async function waitFor( - predicate: () => boolean, - message: string, - timeoutMs = 30_000 -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - function signalTerminalTree( pid: number, signal: NodeJS.Signals, @@ -170,7 +158,11 @@ async function main(): Promise { markerLatch.arm(); terminal.write('\r'); } else { - await waitFor(() => sawThinking, 'Turn activity TUI did not render thinking'); + await waitFor( + () => sawThinking, + 'Turn activity TUI did not render thinking', + 30_000 + ); await waitFor( () => sawTool, 'Turn activity TUI did not render active Bash', diff --git a/packages/cli/tests/support/weightedProviderAdmissionPtyRunner.ts b/packages/cli/tests/support/weightedProviderAdmissionPtyRunner.ts index b341df6ec..29d4a5c20 100644 --- a/packages/cli/tests/support/weightedProviderAdmissionPtyRunner.ts +++ b/packages/cli/tests/support/weightedProviderAdmissionPtyRunner.ts @@ -2,6 +2,7 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { spawn } from 'bun-pty'; import { PersistentStore } from '../../src/context/storage/PersistentStore.js'; +import { waitForCondition as waitFor } from './asyncTestUtils.js'; import { appendBoundedPtyEvidence, latchPtyEvidence, @@ -16,27 +17,6 @@ function required(name: string): string { return value; } -function waitFor( - predicate: () => boolean, - message: string, - timeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (predicate()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - startedAt >= timeoutMs) { - clearInterval(timer); - reject(new Error(message)); - } - }, 50); - }); -} - async function waitForPendingByteSidecar( storageRoot: string, timeoutMs: number From 7d74d9cd5b4bb02dcd9b9b2b983662e40c347d26 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 13:56:31 +0800 Subject: [PATCH 16/59] refactor(test): centralize Blade ACP harness lifecycle --- .../integration/acp-session-fork.test.ts | 55 ++-------------- .../real-api/acp-model-switch.test.ts | 38 +++-------- .../real-api/acp-session-load.test.ts | 56 ++-------------- ...oordination-reclamation-trajectory.test.ts | 52 ++------------- .../support/acp/createBladeAcpHarness.ts | 40 ++++++++++++ .../support/acp/createPairedAcpHarness.ts | 46 ++++++++++++- .../backgroundSubagentCompletionAcpDriver.ts | 64 +------------------ .../cli/tests/support/browserToolAcpDriver.ts | 63 +----------------- .../foregroundBoundedOutputAcpDriver.ts | 61 +++--------------- .../support/goalFinalizationAcpDriver.ts | 64 +------------------ .../support/rootTurnAutoResumeAcpDriver.ts | 56 +--------------- .../subagentResultAdoptionAcpDriver.ts | 64 +------------------ .../support/tokenBudgetHandoffAcpRunner.ts | 57 +---------------- 13 files changed, 133 insertions(+), 583 deletions(-) create mode 100644 packages/cli/tests/support/acp/createBladeAcpHarness.ts diff --git a/packages/cli/tests/integration/acp-session-fork.test.ts b/packages/cli/tests/integration/acp-session-fork.test.ts index ee2821860..8bc93f54f 100644 --- a/packages/cli/tests/integration/acp-session-fork.test.ts +++ b/packages/cli/tests/integration/acp-session-fork.test.ts @@ -10,7 +10,7 @@ import { deriveAcpRemoteHostStateRoot, withValidatedAcpRemoteStateScope, } from '../../src/acp/AcpRemoteWorkspace.js'; -import { BladeAgent } from '../../src/acp/BladeAgent.js'; +import type { BladeAgent } from '../../src/acp/BladeAgent.js'; import { JSONLStore } from '../../src/context/storage/JSONLStore.js'; import { getAcpRemoteSessionFilePath, @@ -21,6 +21,7 @@ import { SessionService } from '../../src/services/SessionService.js'; import { getState, vanillaStore } from '../../src/store/vanilla.js'; import { worktreeManager } from '../../src/worktree/WorktreeManager.js'; import { ControlledFileClient } from '../support/acp/ControlledFileClient.js'; +import { createBladeAcpHarness } from '../support/acp/createBladeAcpHarness.js'; import { createDefaultMockConfig } from '../support/mocks/mockConfig.js'; class DeterministicClient implements acp.Client { @@ -44,55 +45,11 @@ interface PairedAcpHarness { function createHarness( client: acp.Client = new DeterministicClient() ): PairedAcpHarness { - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - let closePromise: Promise | undefined; - - const clientConnection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (connection) => { - agent = new BladeAgent(connection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP Agent was not created'); - const createdAgent = agent; - + const harness = createBladeAcpHarness(client); return { - agent: createdAgent, - clientConnection, - close: () => { - closePromise ??= (async () => { - let firstError: unknown; - try { - await createdAgent.destroy(); - } catch (error) { - firstError = error; - } - - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([clientConnection.closed, agentConnection.closed]); - } catch (error) { - firstError ??= error; - } - - if (firstError !== undefined) throw firstError; - })(); - return closePromise; - }, + agent: harness.agent, + clientConnection: harness.connection, + close: harness.close, }; } diff --git a/packages/cli/tests/integration/real-api/acp-model-switch.test.ts b/packages/cli/tests/integration/real-api/acp-model-switch.test.ts index c5a03e8d7..54b1ee61c 100644 --- a/packages/cli/tests/integration/real-api/acp-model-switch.test.ts +++ b/packages/cli/tests/integration/real-api/acp-model-switch.test.ts @@ -7,7 +7,6 @@ import path from 'node:path'; import { promisify } from 'node:util'; import * as acp from '@agentclientprotocol/sdk'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { BladeAgent } from '../../../src/acp/BladeAgent.js'; import { DEFAULT_CONFIG } from '../../../src/config/defaults.js'; import type { RuntimeConfig } from '../../../src/config/types.js'; import { PersistentStore } from '../../../src/context/storage/PersistentStore.js'; @@ -18,6 +17,7 @@ import { SessionService } from '../../../src/services/SessionService.js'; import { getState } from '../../../src/store/vanilla.js'; import { runWithCwdOverride } from '../../../src/utils/cwd.js'; import { ChildProcessRecordingAcpClient } from '../../support/acp/ChildProcessRecordingAcpClient.js'; +import { createBladeAcpHarness } from '../../support/acp/createBladeAcpHarness.js'; import { startRecordingProviderProxy } from '../../support/recordingProviderProxy.js'; import { assertNoSecrets, @@ -41,28 +41,6 @@ const enabled = Boolean(qualification); const originalStorageRoot = process.env.BLADE_STORAGE_ROOT; let originalConfig: RuntimeConfig | null = null; -function createHarness(client: ChildProcessRecordingAcpClient): { - connection: acp.ClientSideConnection; - agent: BladeAgent; -} { - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - new acp.AgentSideConnection( - (agentConnection) => { - agent = new BladeAgent(agentConnection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP Agent was not created'); - return { connection, agent }; -} - async function readRequestBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of request) { @@ -185,7 +163,9 @@ describe.skipIf(!enabled)('ACP session model switch trajectory (real API)', () = startRecordingProviderProxy(upstreamBaseUrl), ]); const client = new ChildProcessRecordingAcpClient(); - const harness = createHarness(client); + const harness = createBladeAcpHarness(client, { + disposeClient: () => client.close(), + }); const ids = ['compact-global', 'compact-a', 'compact-b']; const priorVariables = ids.map((id) => { const name = getModelApiKeyEnvironmentVariable(id); @@ -324,8 +304,7 @@ describe.skipIf(!enabled)('ACP session model switch trajectory (real API)', () = JSON.stringify({ globalRequests: 0, sessionRequests: [1, 1], model: proModel }) ); } finally { - await harness.agent.destroy(); - await client.close(); + await harness.close(); await Promise.all(proxies.map((proxy) => proxy.close())); for (const { name, previous } of priorVariables) { if (previous === undefined) delete process.env[name]; @@ -339,7 +318,9 @@ describe.skipIf(!enabled)('ACP session model switch trajectory (real API)', () = const workspace = await mkdtemp(path.join(os.tmpdir(), 'blade-acp-model-switch-')); const proxy = await startModelRecordingProxy(); const client = new ChildProcessRecordingAcpClient(); - const harness = createHarness(client); + const harness = createBladeAcpHarness(client, { + disposeClient: () => client.close(), + }); process.env.BLADE_STORAGE_ROOT = path.join(workspace, '.blade-storage'); const flashModelId = `acp-switch-${flashModel}`; const proModelId = `acp-switch-${proModel}`; @@ -487,8 +468,7 @@ describe.skipIf(!enabled)('ACP session model switch trajectory (real API)', () = expect(client.activeTerminalCount()).toBe(0); expect(JSON.stringify(client.sessionUpdates)).not.toContain(apiKey); } finally { - await harness.agent.destroy().catch(() => undefined); - await client.close().catch(() => undefined); + await harness.close().catch(() => undefined); await proxy.close(); WorkspaceTrustService.resetInstance(); await rm(workspace, { recursive: true, force: true }); diff --git a/packages/cli/tests/integration/real-api/acp-session-load.test.ts b/packages/cli/tests/integration/real-api/acp-session-load.test.ts index a00c545ea..21fb5eb32 100644 --- a/packages/cli/tests/integration/real-api/acp-session-load.test.ts +++ b/packages/cli/tests/integration/real-api/acp-session-load.test.ts @@ -13,7 +13,7 @@ import path from 'node:path'; import { promisify } from 'node:util'; import * as acp from '@agentclientprotocol/sdk'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { BladeAgent } from '../../../src/acp/BladeAgent.js'; +import type { BladeAgent } from '../../../src/acp/BladeAgent.js'; import { AcpSession, createLocalAcpSessionRoots } from '../../../src/acp/Session.js'; import { SessionRuntime } from '../../../src/agent/runtime/SessionRuntime.js'; import type { RuntimeConfig } from '../../../src/config/types.js'; @@ -22,6 +22,7 @@ import { WorkspaceTrustService } from '../../../src/security/WorkspaceTrustServi import { SessionService } from '../../../src/services/SessionService.js'; import { getState } from '../../../src/store/vanilla.js'; import { runWithCwdOverride } from '../../../src/utils/cwd.js'; +import { createBladeAcpHarness } from '../../support/acp/createBladeAcpHarness.js'; import { BOUNDED_OUTPUT_PROOF, BOUNDED_OUTPUT_TAIL, @@ -85,55 +86,12 @@ interface PairedAcpHarness { } function createHarness(client: RecordingClient): PairedAcpHarness { - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (agentConnection) => { - agent = new BladeAgent(agentConnection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP Agent was not created'); - const productionAgent = agent; - let closePromise: Promise | undefined; - + const harness = createBladeAcpHarness(client); return { - connection, - agentConnection, - agent: productionAgent, - close: () => { - closePromise ??= (async () => { - let firstError: unknown; - try { - await productionAgent.destroy(); - } catch (error) { - firstError = error; - } - - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([connection.closed, agentConnection.closed]); - } catch (error) { - firstError ??= error; - } - - if (firstError !== undefined) throw firstError; - })(); - return closePromise; - }, + connection: harness.connection, + agentConnection: harness.agentConnection, + agent: harness.agent, + close: harness.close, }; } diff --git a/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts b/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts index e8b2003e9..0b0b0b4d5 100644 --- a/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/keyed-coordination-reclamation-trajectory.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import * as acp from '@agentclientprotocol/sdk'; import { chromium, type Page } from 'playwright'; import { describe, expect, it } from 'vitest'; -import { BladeAgent } from '../../../src/acp/BladeAgent.js'; +import type { BladeAgent } from '../../../src/acp/BladeAgent.js'; import { getSubagentRegistry } from '../../../src/agent/subagents/SubagentRegistry.js'; import { ConfigManager } from '../../../src/config/ConfigManager.js'; @@ -18,6 +18,7 @@ import { sessionInteractionCoordinationStatsForTests } from '../../../src/servic import { SkillRegistry } from '../../../src/skills/SkillRegistry.js'; import { ensureStoreInitialized, getState } from '../../../src/store/vanilla.js'; import { runWithCwdOverride } from '../../../src/utils/cwd.js'; +import { createBladeAcpHarness } from '../../support/acp/createBladeAcpHarness.js'; import { waitForCondition as waitFor } from '../../support/asyncTestUtils.js'; import { findSessionTranscript } from './sessionForkTrajectoryHarness.js'; import { @@ -271,52 +272,11 @@ async function waitForWebCoordinationIdle(): Promise { } function createAcpHarness(client: RecordingAcpClient): AcpHarness { - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (productionConnection) => { - agent = new BladeAgent(productionConnection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP Agent was not created'); - const productionAgent = agent; - let closePromise: Promise | undefined; - + const harness = createBladeAcpHarness(client); return { - connection, - agent: productionAgent, - close: () => { - closePromise ??= (async () => { - let firstError: unknown; - try { - await productionAgent.destroy(); - } catch (error) { - firstError = error; - } - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([connection.closed, agentConnection.closed]); - } catch (error) { - firstError ??= error; - } - if (firstError !== undefined) throw firstError; - })(); - return closePromise; - }, + connection: harness.connection, + agent: harness.agent, + close: harness.close, }; } diff --git a/packages/cli/tests/support/acp/createBladeAcpHarness.ts b/packages/cli/tests/support/acp/createBladeAcpHarness.ts new file mode 100644 index 000000000..d299fea70 --- /dev/null +++ b/packages/cli/tests/support/acp/createBladeAcpHarness.ts @@ -0,0 +1,40 @@ +import type * as acp from '@agentclientprotocol/sdk'; +import { BladeAgent } from '../../../src/acp/BladeAgent.js'; +import { createPairedAcpHarness } from './createPairedAcpHarness.js'; + +export interface BladeAcpHarness { + agent: BladeAgent; + agentConnection: acp.AgentSideConnection; + client: TClient; + connection: acp.ClientSideConnection; + close(): Promise; +} + +export interface BladeAcpHarnessOptions { + prepareConnection?(connection: acp.AgentSideConnection): void; + disposeClient?(): Promise; +} + +export function createBladeAcpHarness( + client: TClient, + options: BladeAcpHarnessOptions = {} +): BladeAcpHarness { + let bladeAgent: BladeAgent | undefined; + const paired = createPairedAcpHarness(client, { + createAgent: (connection) => { + options.prepareConnection?.(connection); + bladeAgent = new BladeAgent(connection); + return bladeAgent; + }, + disposeAgent: (agent) => agent.destroy(), + disposeClient: options.disposeClient, + }); + if (!bladeAgent) throw new Error('Blade ACP Agent was not created'); + return { + agent: bladeAgent, + agentConnection: paired.agentConnection, + client, + connection: paired.clientConnection, + close: paired.close, + }; +} diff --git a/packages/cli/tests/support/acp/createPairedAcpHarness.ts b/packages/cli/tests/support/acp/createPairedAcpHarness.ts index f201dddcc..9b420f4bf 100644 --- a/packages/cli/tests/support/acp/createPairedAcpHarness.ts +++ b/packages/cli/tests/support/acp/createPairedAcpHarness.ts @@ -40,6 +40,12 @@ export interface PairedAcpAppHarness { close(): Promise; } +export interface PairedAcpHarnessOptions { + createAgent?(connection: acp.AgentSideConnection): TAgent; + disposeAgent?(agent: TAgent): Promise; + disposeClient?(): Promise; +} + async function closeWriter(writable: WritableStream): Promise { let writer: WritableStreamDefaultWriter; try { @@ -90,6 +96,7 @@ function createClosableHarness< agentToClient: TransformStream; closeClientConnection?: () => void; getClientCloseError?: () => unknown; + beforeClose?: () => Promise; }): { clientConnection: TClientConnection; agentConnection: acp.AgentSideConnection; @@ -102,6 +109,12 @@ function createClosableHarness< agentConnection: input.agentConnection, close: () => { closePromise ??= (async () => { + let firstError: unknown; + try { + await input.beforeClose?.(); + } catch (error) { + firstError = error; + } const closeError = input.getClientCloseError?.(); input.closeClientConnection?.(); await Promise.all([ @@ -112,6 +125,9 @@ function createClosableHarness< settleWithin(input.clientConnection.closed), settleWithin(input.agentConnection.closed), ]); + if (firstError !== undefined) { + throw firstError; + } if (closeError !== undefined && !isBenignCloseError(closeError)) { throw closeError; } @@ -127,15 +143,25 @@ export function closePairedAcpHarness( return harness.close(); } -export function createPairedAcpHarness(client: acp.Client): PairedAcpHarness { +export function createPairedAcpHarness( + client: acp.Client, + options: PairedAcpHarnessOptions = {} +): PairedAcpHarness { const clientToAgent = new TransformStream(); const agentToClient = new TransformStream(); const clientConnection = new acp.ClientSideConnection( () => client, acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) ); + let ownedAgent: TAgent | undefined; const agentConnection = new acp.AgentSideConnection( - () => new MinimalAgent(), + (connection) => { + if (!options.createAgent) { + return new MinimalAgent(); + } + ownedAgent = options.createAgent(connection); + return ownedAgent; + }, acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) ); return createClosableHarness({ @@ -143,6 +169,22 @@ export function createPairedAcpHarness(client: acp.Client): PairedAcpHarness { agentConnection, clientToAgent, agentToClient, + beforeClose: async () => { + let firstError: unknown; + try { + if (ownedAgent && options.disposeAgent) { + await options.disposeAgent(ownedAgent); + } + } catch (error) { + firstError = error; + } + try { + await options.disposeClient?.(); + } catch (error) { + firstError ??= error; + } + if (firstError !== undefined) throw firstError; + }, }); } diff --git a/packages/cli/tests/support/backgroundSubagentCompletionAcpDriver.ts b/packages/cli/tests/support/backgroundSubagentCompletionAcpDriver.ts index bd3325763..8d07d825d 100644 --- a/packages/cli/tests/support/backgroundSubagentCompletionAcpDriver.ts +++ b/packages/cli/tests/support/backgroundSubagentCompletionAcpDriver.ts @@ -1,8 +1,8 @@ import { access } from 'node:fs/promises'; import * as acp from '@agentclientprotocol/sdk'; -import { BladeAgent } from '../../src/acp/BladeAgent.js'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; import { runWithCwdOverride } from '../../src/utils/cwd.js'; +import { createBladeAcpHarness } from './acp/createBladeAcpHarness.js'; class RecordingClient implements acp.Client { readonly updates: acp.SessionNotification[] = []; @@ -21,66 +21,6 @@ class RecordingClient implements acp.Client { } } -interface AcpHarness { - client: RecordingClient; - connection: acp.ClientSideConnection; - close(): Promise; -} - -function createHarness(): AcpHarness { - const client = new RecordingClient(); - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (productionConnection) => { - agent = new BladeAgent(productionConnection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP background completion Agent was not created'); - const productionAgent = agent; - let closePromise: Promise | undefined; - - return { - client, - connection, - close: () => { - closePromise ??= (async () => { - let firstError: unknown; - try { - await productionAgent.destroy(); - } catch (error) { - firstError = error; - } - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([ - connection.closed.catch(() => undefined), - agentConnection.closed.catch(() => undefined), - ]); - } catch (error) { - firstError ??= error; - } - if (firstError !== undefined) throw firstError; - })(); - return closePromise; - }, - }; -} - function agentText(updates: readonly acp.SessionNotification[]): string { return updates .flatMap((notification) => @@ -116,7 +56,7 @@ export async function runBackgroundSubagentCompletionAcpDriver(input: { secret: string; timeoutMs?: number; }): Promise { - const harness = createHarness(); + const harness = createBladeAcpHarness(new RecordingClient()); try { await runWithCwdOverride(input.workspace, async () => { await harness.connection.initialize({ diff --git a/packages/cli/tests/support/browserToolAcpDriver.ts b/packages/cli/tests/support/browserToolAcpDriver.ts index c2d8b4d84..96a2019d5 100644 --- a/packages/cli/tests/support/browserToolAcpDriver.ts +++ b/packages/cli/tests/support/browserToolAcpDriver.ts @@ -1,69 +1,12 @@ import * as acp from '@agentclientprotocol/sdk'; -import { BladeAgent } from '../../src/acp/BladeAgent.js'; import { runWithCwdOverride } from '../../src/utils/cwd.js'; import type { BrowserToolFixture } from '../integration/real-api/browser-tool-fixture.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { createBladeAcpHarness } from './acp/createBladeAcpHarness.js'; -interface AcpHarness { - client: ChildBackedRecordingAcpClient; - connection: acp.ClientSideConnection; - close(): Promise; -} - -function createHarness(): AcpHarness { +function createHarness() { const client = new ChildBackedRecordingAcpClient(); - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (productionConnection) => { - agent = new BladeAgent(productionConnection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP Browser Tool Agent was not created'); - const productionAgent = agent; - let closePromise: Promise | undefined; - return { - client, - connection, - close: () => { - closePromise ??= (async () => { - let firstError: unknown; - try { - await productionAgent.destroy(); - } catch (error) { - firstError = error; - } - await client.close().catch((error) => { - firstError ??= error; - }); - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([ - connection.closed.catch(() => undefined), - agentConnection.closed.catch(() => undefined), - ]); - } catch (error) { - firstError ??= error; - } - if (firstError !== undefined) throw firstError; - })(); - return closePromise; - }, - }; + return createBladeAcpHarness(client, { disposeClient: () => client.close() }); } function finalText(updates: readonly acp.SessionNotification[]): string { diff --git a/packages/cli/tests/support/foregroundBoundedOutputAcpDriver.ts b/packages/cli/tests/support/foregroundBoundedOutputAcpDriver.ts index 0ac5dff07..b370a52bd 100644 --- a/packages/cli/tests/support/foregroundBoundedOutputAcpDriver.ts +++ b/packages/cli/tests/support/foregroundBoundedOutputAcpDriver.ts @@ -2,11 +2,11 @@ import { execFile } from 'node:child_process'; import path from 'node:path'; import { promisify } from 'node:util'; import * as acp from '@agentclientprotocol/sdk'; -import { BladeAgent } from '../../src/acp/BladeAgent.js'; import { runWithCwdOverride } from '../../src/utils/cwd.js'; import type { ProcessIdentity } from '../../src/utils/process/ProcessIdentity.js'; import type { ForegroundBoundedOutputFixture } from '../integration/real-api/foregroundBoundedOutputFixture.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { createBladeAcpHarness } from './acp/createBladeAcpHarness.js'; const execFileAsync = promisify(execFile); const ACP_EVIDENCE_PREFIX = '__BLADE_BOUNDED_ACP_EVIDENCE__'; @@ -24,23 +24,15 @@ interface PairedHarness { function createHarness(options: { sessionUpdateDelayMs?: number } = {}): PairedHarness { const client = new ChildBackedRecordingAcpClient(); - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; const egressMetrics = { sessionUpdateCalls: 0, sessionUpdateInFlight: 0, maxSessionUpdateInFlight: 0, }; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (productionConnection) => { - const sessionUpdate = - productionConnection.sessionUpdate.bind(productionConnection); - productionConnection.sessionUpdate = async (params) => { + const paired = createBladeAcpHarness(client, { + prepareConnection: (connection) => { + const sessionUpdate = connection.sessionUpdate.bind(connection); + connection.sessionUpdate = async (params) => { egressMetrics.sessionUpdateCalls += 1; egressMetrics.sessionUpdateInFlight += 1; egressMetrics.maxSessionUpdateInFlight = Math.max( @@ -58,49 +50,12 @@ function createHarness(options: { sessionUpdateDelayMs?: number } = {}): PairedH egressMetrics.sessionUpdateInFlight -= 1; } }; - agent = new BladeAgent(productionConnection); - return agent; }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP bounded output Agent was not created'); - const productionAgent = agent; - let closePromise: Promise | undefined; + disposeClient: () => client.close(), + }); return { - client, - connection, + ...paired, egressMetrics, - close: () => { - closePromise ??= (async () => { - let firstError: unknown; - try { - await productionAgent.destroy(); - } catch (error) { - firstError = error; - } - await client.close().catch((error) => { - firstError ??= error; - }); - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([ - connection.closed.catch(() => undefined), - agentConnection.closed.catch(() => undefined), - ]); - } catch (error) { - firstError ??= error; - } - if (firstError !== undefined) throw firstError; - })(); - return closePromise; - }, }; } diff --git a/packages/cli/tests/support/goalFinalizationAcpDriver.ts b/packages/cli/tests/support/goalFinalizationAcpDriver.ts index c4af2fa63..3d75e2238 100644 --- a/packages/cli/tests/support/goalFinalizationAcpDriver.ts +++ b/packages/cli/tests/support/goalFinalizationAcpDriver.ts @@ -1,6 +1,6 @@ import * as acp from '@agentclientprotocol/sdk'; -import { BladeAgent } from '../../src/acp/BladeAgent.js'; import { runWithCwdOverride } from '../../src/utils/cwd.js'; +import { createBladeAcpHarness } from './acp/createBladeAcpHarness.js'; class RecordingClient implements acp.Client { readonly updates: acp.SessionNotification[] = []; @@ -19,66 +19,6 @@ class RecordingClient implements acp.Client { } } -interface AcpHarness { - client: RecordingClient; - connection: acp.ClientSideConnection; - close(): Promise; -} - -function createHarness(): AcpHarness { - const client = new RecordingClient(); - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (productionConnection) => { - agent = new BladeAgent(productionConnection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP Goal finalization Agent was not created'); - const productionAgent = agent; - let closePromise: Promise | undefined; - - return { - client, - connection, - close: () => { - closePromise ??= (async () => { - let firstError: unknown; - try { - await productionAgent.destroy(); - } catch (error) { - firstError = error; - } - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([ - connection.closed.catch(() => undefined), - agentConnection.closed.catch(() => undefined), - ]); - } catch (error) { - firstError ??= error; - } - if (firstError !== undefined) throw firstError; - })(); - return closePromise; - }, - }; -} - function agentText(updates: readonly acp.SessionNotification[]): string { return updates .flatMap((notification) => @@ -119,7 +59,7 @@ export async function runGoalFinalizationAcpDriver(input: { expectedFollowup: string; secret: string; }): Promise { - const harness = createHarness(); + const harness = createBladeAcpHarness(new RecordingClient()); try { await runWithCwdOverride(input.workspace, async () => { await harness.connection.initialize({ diff --git a/packages/cli/tests/support/rootTurnAutoResumeAcpDriver.ts b/packages/cli/tests/support/rootTurnAutoResumeAcpDriver.ts index 21239f6b0..d76e04a7b 100644 --- a/packages/cli/tests/support/rootTurnAutoResumeAcpDriver.ts +++ b/packages/cli/tests/support/rootTurnAutoResumeAcpDriver.ts @@ -1,8 +1,8 @@ import { access } from 'node:fs/promises'; import * as acp from '@agentclientprotocol/sdk'; -import { BladeAgent } from '../../src/acp/BladeAgent.js'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; import { runWithCwdOverride } from '../../src/utils/cwd.js'; +import { createBladeAcpHarness } from './acp/createBladeAcpHarness.js'; class RecordingClient implements acp.Client { readonly updates: acp.SessionNotification[] = []; @@ -28,59 +28,7 @@ interface AcpHarness { } function createHarness(): AcpHarness { - const client = new RecordingClient(); - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (productionConnection) => { - agent = new BladeAgent(productionConnection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP root-turn Agent was not created'); - const productionAgent = agent; - let closePromise: Promise | undefined; - - return { - client, - connection, - close: () => { - closePromise ??= (async () => { - let firstError: unknown; - try { - await productionAgent.destroy(); - } catch (error) { - firstError = error; - } - - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([ - connection.closed.catch(() => undefined), - agentConnection.closed.catch(() => undefined), - ]); - } catch (error) { - firstError ??= error; - } - - if (firstError !== undefined) throw firstError; - })(); - return closePromise; - }, - }; + return createBladeAcpHarness(new RecordingClient()); } function finalAgentText(updates: readonly acp.SessionNotification[]): string { diff --git a/packages/cli/tests/support/subagentResultAdoptionAcpDriver.ts b/packages/cli/tests/support/subagentResultAdoptionAcpDriver.ts index 7631ca25f..d4f7e1999 100644 --- a/packages/cli/tests/support/subagentResultAdoptionAcpDriver.ts +++ b/packages/cli/tests/support/subagentResultAdoptionAcpDriver.ts @@ -1,8 +1,8 @@ import { access } from 'node:fs/promises'; import * as acp from '@agentclientprotocol/sdk'; -import { BladeAgent } from '../../src/acp/BladeAgent.js'; import { getSessionInboxFilePath } from '../../src/context/storage/pathUtils.js'; import { runWithCwdOverride } from '../../src/utils/cwd.js'; +import { createBladeAcpHarness } from './acp/createBladeAcpHarness.js'; class RecordingClient implements acp.Client { readonly updates: acp.SessionNotification[] = []; @@ -21,66 +21,6 @@ class RecordingClient implements acp.Client { } } -interface AcpHarness { - client: RecordingClient; - connection: acp.ClientSideConnection; - close(): Promise; -} - -function createHarness(): AcpHarness { - const client = new RecordingClient(); - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (productionConnection) => { - agent = new BladeAgent(productionConnection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('ACP subagent adoption Agent was not created'); - const productionAgent = agent; - let closePromise: Promise | undefined; - - return { - client, - connection, - close: () => { - closePromise ??= (async () => { - let firstError: unknown; - try { - await productionAgent.destroy(); - } catch (error) { - firstError = error; - } - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([ - connection.closed.catch(() => undefined), - agentConnection.closed.catch(() => undefined), - ]); - } catch (error) { - firstError ??= error; - } - if (firstError !== undefined) throw firstError; - })(); - return closePromise; - }, - }; -} - function agentText(updates: readonly acp.SessionNotification[]): string { return updates .flatMap((notification) => @@ -116,7 +56,7 @@ export async function runSubagentResultAdoptionAcpDriver(input: { secret: string; timeoutMs?: number; }): Promise { - const harness = createHarness(); + const harness = createBladeAcpHarness(new RecordingClient()); try { await runWithCwdOverride(input.workspace, async () => { await harness.connection.initialize({ diff --git a/packages/cli/tests/support/tokenBudgetHandoffAcpRunner.ts b/packages/cli/tests/support/tokenBudgetHandoffAcpRunner.ts index 9b5a0ec9b..79e46f4fb 100644 --- a/packages/cli/tests/support/tokenBudgetHandoffAcpRunner.ts +++ b/packages/cli/tests/support/tokenBudgetHandoffAcpRunner.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import * as acp from '@agentclientprotocol/sdk'; -import { BladeAgent } from '../../src/acp/BladeAgent.js'; import { assertValidSessionId, isValidSessionId, @@ -12,6 +11,7 @@ import { ensureStoreInitialized } from '../../src/store/vanilla.js'; import { runWithCwdOverride } from '../../src/utils/cwd.js'; import { processIdentityMatches } from '../../src/utils/process/ProcessIdentity.js'; import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; +import { createBladeAcpHarness } from './acp/createBladeAcpHarness.js'; interface RunnerInput { mode: 'task' | 'load'; @@ -264,60 +264,7 @@ function loadInput(): RunnerInput { function createHarness(forbidden: readonly string[]): Harness { const client = new RecordingClient(forbidden); - const clientToAgent = new TransformStream(); - const agentToClient = new TransformStream(); - let agent: BladeAgent | undefined; - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream(clientToAgent.writable, agentToClient.readable) - ); - const agentConnection = new acp.AgentSideConnection( - (productionConnection) => { - agent = new BladeAgent(productionConnection); - return agent; - }, - acp.ndJsonStream(agentToClient.writable, clientToAgent.readable) - ); - if (!agent) throw new Error('Token-budget ACP Agent was not created'); - const productionAgent = agent; - let closePromise: Promise | undefined; - return { - client, - connection, - close: () => { - closePromise ??= (async () => { - let failed = false; - let firstError: unknown; - await productionAgent.destroy().catch((error) => { - failed = true; - firstError = error; - }); - await client.close().catch((error) => { - if (!failed) firstError = error; - failed = true; - }); - try { - const clientWriter = clientToAgent.writable.getWriter(); - const agentWriter = agentToClient.writable.getWriter(); - try { - await Promise.all([clientWriter.close(), agentWriter.close()]); - } finally { - clientWriter.releaseLock(); - agentWriter.releaseLock(); - } - await Promise.all([ - connection.closed.catch(() => undefined), - agentConnection.closed.catch(() => undefined), - ]); - } catch (error) { - if (!failed) firstError = error; - failed = true; - } - if (failed) throw firstError; - })(); - return closePromise; - }, - }; + return createBladeAcpHarness(client, { disposeClient: () => client.close() }); } export function finalAgentTextFromUpdates( From 891423c4a49d5a3b01d31fa7f49446c8f3435068 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 14:07:28 +0800 Subject: [PATCH 17/59] refactor(test): consolidate coordinator and cursor fixtures --- .../services/PendingResumeCoordinator.test.ts | 271 +++---- .../session-surface-cursor-registry.test.ts | 760 +++++++----------- 2 files changed, 366 insertions(+), 665 deletions(-) diff --git a/packages/cli/tests/unit/platform/ui/services/PendingResumeCoordinator.test.ts b/packages/cli/tests/unit/platform/ui/services/PendingResumeCoordinator.test.ts index f6d1d8b1e..23c46aab5 100644 --- a/packages/cli/tests/unit/platform/ui/services/PendingResumeCoordinator.test.ts +++ b/packages/cli/tests/unit/platform/ui/services/PendingResumeCoordinator.test.ts @@ -7,6 +7,7 @@ import { import { taskFailureForCode } from '../../../../../src/context/taskFailure.js'; import { PendingResumeCoordinator, + type PendingResumeCoordinatorOptions, type PendingResumeRunResult, } from '../../../../../src/ui/services/PendingResumeCoordinator.js'; @@ -65,6 +66,33 @@ async function settleAsyncWork(): Promise { await Promise.resolve(); } +function createCoordinatorHarness( + options: Partial = {} +) { + const callbacks: Array<() => void> = []; + const terminalFailures = vi.fn(); + const run = options.run ?? vi.fn(async () => completed()); + const coordinator = new PendingResumeCoordinator({ + ...options, + canRun: options.canRun ?? (() => true), + run, + onTerminalFailure: options.onTerminalFailure ?? terminalFailures, + scheduleMicrotask: + options.scheduleMicrotask ?? ((callback) => callbacks.push(callback)), + }); + return { callbacks, coordinator, run, terminalFailures }; +} + +function requestRun( + harness: Pick< + ReturnType, + 'callbacks' | 'coordinator' + > +): void { + harness.coordinator.request(); + runNextMicrotask(harness.callbacks); +} + describe('PendingResumeCoordinator', () => { beforeEach(() => { vi.useFakeTimers({ now: 10_000 }); @@ -76,14 +104,9 @@ describe('PendingResumeCoordinator', () => { }); it('coalesces requests while a run is scheduled or in flight', async () => { - const callbacks: Array<() => void> = []; const completion = deferred(); const run = vi.fn(() => completion.promise); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, - run, - scheduleMicrotask: (callback) => callbacks.push(callback), - }); + const { callbacks, coordinator } = createCoordinatorHarness({ run }); coordinator.request(); coordinator.request(); @@ -103,13 +126,11 @@ describe('PendingResumeCoordinator', () => { }); it('retains a request that cannot start until the owner becomes idle', () => { - const callbacks: Array<() => void> = []; let idle = false; const run = vi.fn(async () => completed()); - const coordinator = new PendingResumeCoordinator({ + const { callbacks, coordinator } = createCoordinatorHarness({ canRun: () => idle, run, - scheduleMicrotask: (callback) => callbacks.push(callback), }); coordinator.request(); @@ -125,16 +146,10 @@ describe('PendingResumeCoordinator', () => { }); it('waits for a new idle edge instead of self-scheduling deferred work', async () => { - const callbacks: Array<() => void> = []; const run = vi.fn(async () => deferredResult()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, - run, - scheduleMicrotask: (callback) => callbacks.push(callback), - }); + const { callbacks, coordinator } = createCoordinatorHarness({ run }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(run).toHaveBeenCalledOnce(); @@ -147,20 +162,14 @@ describe('PendingResumeCoordinator', () => { }); it('retains exactly one idle edge delivered during an in-flight deferred run', async () => { - const callbacks: Array<() => void> = []; const firstRun = deferred(); const run = vi .fn<() => Promise>() .mockImplementationOnce(() => firstRun.promise) .mockResolvedValueOnce(completed()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, - run, - scheduleMicrotask: (callback) => callbacks.push(callback), - }); + const { callbacks, coordinator } = createCoordinatorHarness({ run }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); coordinator.notifyIdle(); coordinator.notifyIdle(); firstRun.resolve(deferredResult()); @@ -175,7 +184,6 @@ describe('PendingResumeCoordinator', () => { }); it('turns a rejected run into one canonical terminal failure and starts a fresh episode', async () => { - const callbacks: Array<() => void> = []; const terminalFailures = vi.fn(() => { throw new Error('opaque terminal UI projection failure'); }); @@ -187,16 +195,13 @@ describe('PendingResumeCoordinator', () => { }) ) .mockResolvedValueOnce(completed()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator } = createCoordinatorHarness({ run, sessionIdentity: 'rejected-run-session', onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(terminalFailures).toHaveBeenCalledOnce(); @@ -219,7 +224,6 @@ describe('PendingResumeCoordinator', () => { }); it('isolates a throwing terminal callback at the deadline and starts a fresh episode', async () => { - const callbacks: Array<() => void> = []; const firstRun = deferred(); const run = vi .fn<() => Promise>() @@ -228,16 +232,13 @@ describe('PendingResumeCoordinator', () => { const terminalFailures = vi.fn(() => { throw new Error('opaque terminal UI projection failure'); }); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator } = createCoordinatorHarness({ run, sessionIdentity: 'throwing-terminal-deadline-session', onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await vi.advanceTimersByTimeAsync(PENDING_RESUME_RECOVERY_BUDGET_MS); expect(terminalFailures).toHaveBeenCalledOnce(); @@ -258,8 +259,6 @@ describe('PendingResumeCoordinator', () => { }); it('fails closed when result and evidence report different failures', async () => { - const callbacks: Array<() => void> = []; - const terminalFailures = vi.fn(); const taskFailure = taskFailureForCode('authentication'); const run = vi.fn( async (): Promise => ({ @@ -275,16 +274,12 @@ describe('PendingResumeCoordinator', () => { }, }) ); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run, sessionIdentity: 'contradictory-failure-session', - onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(terminalFailures).toHaveBeenCalledOnce(); @@ -298,8 +293,6 @@ describe('PendingResumeCoordinator', () => { }); it('fails closed when result and evidence capacity resources differ', async () => { - const callbacks: Array<() => void> = []; - const terminalFailures = vi.fn(); const taskFailure = { ...taskFailureForCode('capacity'), resource: 'pending_count' as const, @@ -321,16 +314,12 @@ describe('PendingResumeCoordinator', () => { }, }) ); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run, sessionIdentity: 'contradictory-resource-session', - onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(terminalFailures).toHaveBeenCalledWith({ @@ -343,13 +332,10 @@ describe('PendingResumeCoordinator', () => { it('clears a scheduled token when the microtask scheduler throws', async () => { const callbacks: Array<() => void> = []; - const terminalFailures = vi.fn(); let schedulingCalls = 0; const run = vi.fn(async () => completed()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { coordinator, terminalFailures } = createCoordinatorHarness({ run, - onTerminalFailure: terminalFailures, scheduleMicrotask: (callback) => { schedulingCalls++; if (schedulingCalls === 1) { @@ -378,15 +364,10 @@ describe('PendingResumeCoordinator', () => { }); it('clears a partial episode when deadline timer creation throws', async () => { - const callbacks: Array<() => void> = []; - const terminalFailures = vi.fn(); let timerCalls = 0; const run = vi.fn(async () => completed()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run, - onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), setTimer: (callback, delayMs) => { timerCalls++; if (timerCalls === 1) { @@ -396,8 +377,7 @@ describe('PendingResumeCoordinator', () => { }, }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(run).not.toHaveBeenCalled(); @@ -409,8 +389,7 @@ describe('PendingResumeCoordinator', () => { }); expect(vi.getTimerCount()).toBe(0); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(run).toHaveBeenCalledOnce(); expect(terminalFailures).toHaveBeenCalledOnce(); @@ -418,19 +397,14 @@ describe('PendingResumeCoordinator', () => { }); it('clears deadline and backoff state when retry timer creation throws', async () => { - const callbacks: Array<() => void> = []; - const terminalFailures = vi.fn(); let timerCalls = 0; const run = vi .fn<() => Promise>() .mockResolvedValueOnce(replaySafeFailure()) .mockResolvedValueOnce(completed()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run, sessionIdentity: 'retry-timer-failure-session', - onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), setTimer: (callback, delayMs) => { timerCalls++; if (timerCalls === 2) { @@ -440,8 +414,7 @@ describe('PendingResumeCoordinator', () => { }, }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(run).toHaveBeenCalledOnce(); @@ -453,8 +426,7 @@ describe('PendingResumeCoordinator', () => { }); expect(vi.getTimerCount()).toBe(0); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(run).toHaveBeenCalledTimes(2); expect(terminalFailures).toHaveBeenCalledOnce(); @@ -464,20 +436,16 @@ describe('PendingResumeCoordinator', () => { it('waits for the exact shared-policy delay before retrying', async () => { const sessionIdentity = JSON.stringify(['/workspace', 'session']); const retryDelayMs = stablePendingResumeRetryDelay(sessionIdentity, 1); - const callbacks: Array<() => void> = []; const run = vi .fn<() => Promise>() .mockResolvedValueOnce(replaySafeFailure()) .mockResolvedValueOnce(completed()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator } = createCoordinatorHarness({ run, sessionIdentity, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); await vi.advanceTimersByTimeAsync(retryDelayMs - 1); @@ -495,25 +463,21 @@ describe('PendingResumeCoordinator', () => { it('keeps one backoff timer and one episode across repeated wakeups', async () => { const sessionIdentity = 'stable-session'; const retryDelayMs = stablePendingResumeRetryDelay(sessionIdentity, 1); - const callbacks: Array<() => void> = []; const timerDelays: number[] = []; const run = vi .fn<() => Promise>() .mockResolvedValueOnce(replaySafeFailure()) .mockResolvedValueOnce(completed()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator } = createCoordinatorHarness({ run, sessionIdentity, - scheduleMicrotask: (callback) => callbacks.push(callback), setTimer: (callback, delayMs) => { timerDelays.push(delayMs); return setTimeout(callback, delayMs); }, }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(timerDelays).toEqual([PENDING_RESUME_RECOVERY_BUDGET_MS, retryDelayMs]); @@ -534,21 +498,18 @@ describe('PendingResumeCoordinator', () => { it('waits for an idle edge when the retry timer expires while busy', async () => { const sessionIdentity = 'busy-session'; const retryDelayMs = stablePendingResumeRetryDelay(sessionIdentity, 1); - const callbacks: Array<() => void> = []; let idle = true; const run = vi .fn<() => Promise>() .mockResolvedValueOnce(replaySafeFailure()) .mockResolvedValueOnce(completed()); - const coordinator = new PendingResumeCoordinator({ + const { callbacks, coordinator } = createCoordinatorHarness({ canRun: () => idle, run, sessionIdentity, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); idle = false; @@ -569,15 +530,10 @@ describe('PendingResumeCoordinator', () => { it('terminates exactly once after four replay-safe failures', async () => { const sessionIdentity = 'exhausted-session'; - const callbacks: Array<() => void> = []; - const terminalFailures = vi.fn(); const run = vi.fn(async () => replaySafeFailure()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run, sessionIdentity, - onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); coordinator.request(); @@ -606,29 +562,23 @@ describe('PendingResumeCoordinator', () => { it('does not install a retry timer when the remaining deadline is insufficient', async () => { const sessionIdentity = 'budget-session'; const retryDelayMs = stablePendingResumeRetryDelay(sessionIdentity, 1); - const callbacks: Array<() => void> = []; const timerDelays: number[] = []; - const terminalFailures = vi.fn(); let currentTime = 1_000; const run = vi.fn(async () => { currentTime += PENDING_RESUME_RECOVERY_BUDGET_MS - retryDelayMs + 1; return replaySafeFailure(); }); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run, sessionIdentity, - onTerminalFailure: terminalFailures, now: () => currentTime, - scheduleMicrotask: (callback) => callbacks.push(callback), setTimer: (callback, delayMs) => { timerDelays.push(delayMs); return setTimeout(callback, delayMs); }, }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(timerDelays).toEqual([PENDING_RESUME_RECOVERY_BUDGET_MS]); @@ -643,7 +593,6 @@ describe('PendingResumeCoordinator', () => { it.each(['resolve', 'reject'] as const)( 'waits for timed-out attempt cleanup before a new wake when the old run will %s', async (outcome) => { - const callbacks: Array<() => void> = []; let finish!: () => void; const first = new Promise((resolve, reject) => { finish = () => @@ -657,17 +606,16 @@ describe('PendingResumeCoordinator', () => { signals.push(signal); return signals.length === 1 ? first : second.promise; }); - const failures = vi.fn(); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { + callbacks, + coordinator, + terminalFailures: failures, + } = createCoordinatorHarness({ run, sessionIdentity: 'cleanup-before-new-episode', - onTerminalFailure: failures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); try { - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await vi.advanceTimersByTimeAsync(PENDING_RESUME_RECOVERY_BUDGET_MS); expect(signals[0]?.aborted).toBe(true); expect(failures).toHaveBeenCalledOnce(); @@ -697,19 +645,17 @@ describe('PendingResumeCoordinator', () => { ); it('does not restart an exhausted attempt without a new wake', async () => { - const callbacks: Array<() => void> = []; const first = deferred(); const run = vi.fn(() => first.promise); - const failures = vi.fn(); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { + callbacks, + coordinator, + terminalFailures: failures, + } = createCoordinatorHarness({ run, - onTerminalFailure: failures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); try { - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await vi.advanceTimersByTimeAsync(PENDING_RESUME_RECOVERY_BUDGET_MS); coordinator.notifyIdle(); first.resolve(replaySafeFailure()); @@ -725,18 +671,16 @@ describe('PendingResumeCoordinator', () => { }); it('discards a retained wake when disposed during exhausted attempt cleanup', async () => { - const callbacks: Array<() => void> = []; const first = deferred(); const run = vi.fn(() => first.promise); - const failures = vi.fn(); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { + callbacks, + coordinator, + terminalFailures: failures, + } = createCoordinatorHarness({ run, - onTerminalFailure: failures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await vi.advanceTimersByTimeAsync(PENDING_RESUME_RECOVERY_BUDGET_MS); coordinator.request(); coordinator.dispose(); @@ -751,21 +695,18 @@ describe('PendingResumeCoordinator', () => { }); it('waits for the foreground owner after exhausted attempt cleanup releases', async () => { - const callbacks: Array<() => void> = []; const first = deferred(); let idle = true; const run = vi .fn<() => Promise>() .mockReturnValueOnce(first.promise) .mockResolvedValueOnce(completed()); - const coordinator = new PendingResumeCoordinator({ + const { callbacks, coordinator } = createCoordinatorHarness({ canRun: () => idle, run, - scheduleMicrotask: (callback) => callbacks.push(callback), }); try { - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await vi.advanceTimersByTimeAsync(PENDING_RESUME_RECOVERY_BUDGET_MS); coordinator.request(); idle = false; @@ -787,21 +728,17 @@ describe('PendingResumeCoordinator', () => { }); it('retains a wake requested synchronously by the deadline callback until cleanup', async () => { - const callbacks: Array<() => void> = []; const first = deferred(); const run = vi .fn<() => Promise>() .mockReturnValueOnce(first.promise) .mockResolvedValueOnce(completed()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator } = createCoordinatorHarness({ run, onTerminalFailure: () => coordinator.request(), - scheduleMicrotask: (callback) => callbacks.push(callback), }); try { - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await vi.advanceTimersByTimeAsync(PENDING_RESUME_RECOVERY_BUDGET_MS); expect(callbacks).toHaveLength(0); await vi.advanceTimersByTimeAsync(PENDING_RESUME_RECOVERY_BUDGET_MS); @@ -820,7 +757,6 @@ describe('PendingResumeCoordinator', () => { }); it('aborts at the absolute deadline and ignores the late result by attempt token', async () => { - const callbacks: Array<() => void> = []; const firstRun = deferred(); const secondRun = deferred(); const signals: AbortSignal[] = []; @@ -829,16 +765,13 @@ describe('PendingResumeCoordinator', () => { signals.push(signal); return signals.length === 1 ? firstRun.promise : secondRun.promise; }); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator } = createCoordinatorHarness({ run, sessionIdentity: 'deadline-session', onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await vi.advanceTimersByTimeAsync(PENDING_RESUME_RECOVERY_BUDGET_MS); expect(signals[0]?.aborted).toBe(true); @@ -866,24 +799,18 @@ describe('PendingResumeCoordinator', () => { }); it('clears backoff and deadline timers and makes their callbacks inert on dispose', async () => { - const callbacks: Array<() => void> = []; const timerCallbacks: Array<() => void> = []; - const terminalFailures = vi.fn(); const run = vi.fn(async () => replaySafeFailure()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run, sessionIdentity: 'disposed-session', - onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), setTimer: (callback, delayMs) => { timerCallbacks.push(callback); return setTimeout(callback, delayMs); }, }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(timerCallbacks).toHaveLength(2); @@ -898,22 +825,16 @@ describe('PendingResumeCoordinator', () => { }); it('aborts an in-flight run on dispose and ignores its late completion', async () => { - const callbacks: Array<() => void> = []; const completion = deferred(); let runSignal: AbortSignal | undefined; - const terminalFailures = vi.fn(); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run: (signal) => { runSignal = signal; return completion.promise; }, - onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); expect(runSignal?.aborted).toBe(false); coordinator.dispose(); @@ -927,23 +848,17 @@ describe('PendingResumeCoordinator', () => { }); it('starts a fresh episode for a wake received during a successful run', async () => { - const callbacks: Array<() => void> = []; const firstRun = deferred(); - const terminalFailures = vi.fn(); const run = vi .fn<() => Promise>() .mockImplementationOnce(() => firstRun.promise) .mockResolvedValueOnce(terminalFailure()); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run, sessionIdentity: 'new-episode-session', - onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); coordinator.request(); firstRun.resolve(completed()); await settleAsyncWork(); @@ -962,19 +877,13 @@ describe('PendingResumeCoordinator', () => { it.each(['goal', 'preflight'] as const)( 'fails %s work once without entering pending-input retry policy', async (workKind) => { - const callbacks: Array<() => void> = []; - const terminalFailures = vi.fn(); const run = vi.fn(async () => terminalFailure(workKind)); - const coordinator = new PendingResumeCoordinator({ - canRun: () => true, + const { callbacks, coordinator, terminalFailures } = createCoordinatorHarness({ run, sessionIdentity: 'non-pending-session', - onTerminalFailure: terminalFailures, - scheduleMicrotask: (callback) => callbacks.push(callback), }); - coordinator.request(); - runNextMicrotask(callbacks); + requestRun({ callbacks, coordinator }); await settleAsyncWork(); expect(run).toHaveBeenCalledOnce(); diff --git a/packages/cli/tests/unit/services/session-surface-cursor-registry.test.ts b/packages/cli/tests/unit/services/session-surface-cursor-registry.test.ts index 5edb0841c..ab6536789 100644 --- a/packages/cli/tests/unit/services/session-surface-cursor-registry.test.ts +++ b/packages/cli/tests/unit/services/session-surface-cursor-registry.test.ts @@ -8,6 +8,7 @@ import { MAX_SESSION_SURFACE_FROZEN_SNAPSHOT_BYTES, SessionSurfaceCursorRegistry, SessionSurfaceCursorRegistryError, + type SessionSurfaceCursorRegistryErrorCode, } from '../../../src/services/SessionSurfaceCursorRegistry.js'; function createClock(start = 0) { @@ -48,25 +49,65 @@ async function flushMicrotasks(): Promise { await Promise.resolve(); } +const ERROR_EXPECTATIONS = { + session_surface_cursor_invalid: { + retryable: false, + message: 'session surface cursor is invalid', + }, + session_surface_snapshot_changed: { + retryable: false, + message: 'session surface snapshot changed', + }, + session_surface_capacity: { + retryable: true, + message: 'session surface cursor capacity exceeded', + }, + session_surface_unavailable: { + retryable: true, + message: 'session surface cursor registry is unavailable', + }, +} satisfies Record< + SessionSurfaceCursorRegistryErrorCode, + { retryable: boolean; message: string } +>; + function expectRegistryError( error: unknown, - expected: { - code: - | 'session_surface_cursor_invalid' - | 'session_surface_snapshot_changed' - | 'session_surface_capacity' - | 'session_surface_unavailable'; - retryable: boolean; - message: string; - } -) { + code: SessionSurfaceCursorRegistryErrorCode +): SessionSurfaceCursorRegistryError { expect(error).toBeInstanceOf(SessionSurfaceCursorRegistryError); if (!(error instanceof SessionSurfaceCursorRegistryError)) { throw new Error('expected SessionSurfaceCursorRegistryError'); } - expect(error.code).toBe(expected.code); - expect(error.retryable).toBe(expected.retryable); - expect(error.message).toBe(expected.message); + expect(error).toMatchObject({ code, ...ERROR_EXPECTATIONS[code] }); + return error; +} + +async function expectRegistryRejection( + promise: Promise, + code: SessionSurfaceCursorRegistryErrorCode, + inspect?: (error: SessionSurfaceCursorRegistryError) => void +): Promise { + await expect(promise).rejects.toSatisfy((error: unknown) => { + const registryError = expectRegistryError(error, code); + inspect?.(registryError); + return true; + }); +} + +function expectRegistryThrow( + action: () => unknown, + code: SessionSurfaceCursorRegistryErrorCode, + inspect?: (error: SessionSurfaceCursorRegistryError) => void +): void { + try { + action(); + } catch (error) { + const registryError = expectRegistryError(error, code); + inspect?.(registryError); + return; + } + throw new Error(`expected ${code}`); } describe('SessionSurfaceCursorRegistry', () => { @@ -140,63 +181,40 @@ describe('SessionSurfaceCursorRegistry', () => { request: { limit: 1 }, }); - const fixedMessage = 'session surface cursor is invalid'; - - await expect( - registry.redeemCatalogCursor({ - token: 'not-a-session-surface-token', - scopeKey: 'scope-a', - epoch: 'epoch-1', - revision: 'revision-1', - request: { limit: 1 }, - loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: fixedMessage, - }); - return true; - }); - - await expect( - registry.redeemHistoryCursor({ - token, - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - request: { limit: 1 }, - loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: fixedMessage, - }); - return true; - }); - - await expect( - registry.redeemCatalogCursor({ - token: 'session-surface-catalog:missing', - scopeKey: 'scope-a', - epoch: 'epoch-1', - revision: 'revision-1', - request: { limit: 1 }, - loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: fixedMessage, - }); - return true; - }); + const invalidRedemptions = [ + () => + registry.redeemCatalogCursor({ + token: 'not-a-session-surface-token', + scopeKey: 'scope-a', + epoch: 'epoch-1', + revision: 'revision-1', + request: { limit: 1 }, + loader: async () => ({ page: [] }), + }), + () => + registry.redeemHistoryCursor({ + token, + locatorDigest: 'locator-a', + transcriptFingerprint: 'fingerprint-a', + request: { limit: 1 }, + loader: async () => ({ page: [] }), + }), + () => + registry.redeemCatalogCursor({ + token: 'session-surface-catalog:missing', + scopeKey: 'scope-a', + epoch: 'epoch-1', + revision: 'revision-1', + request: { limit: 1 }, + loader: async () => ({ page: [] }), + }), + ]; + for (const redeem of invalidRedemptions) { + await expectRegistryRejection(redeem(), 'session_surface_cursor_invalid'); + } clock.advance(51); - await expect( + await expectRegistryRejection( registry.redeemCatalogCursor({ token, scopeKey: 'scope-a', @@ -204,15 +222,9 @@ describe('SessionSurfaceCursorRegistry', () => { revision: 'revision-1', request: { limit: 1 }, loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: fixedMessage, - }); - return true; - }); + }), + 'session_surface_cursor_invalid' + ); }); it('replays catalog pages idempotently, single-flights concurrent redemption, refreshes TTL on access, and passes stored catalog state to the loader', async () => { @@ -372,14 +384,10 @@ describe('SessionSurfaceCursorRegistry', () => { page: ['this-cached-page-is-intentionally-too-large-for-the-budget'], }); - await expect(Promise.all([first, second])).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_capacity', - retryable: true, - message: 'session surface cursor capacity exceeded', - }); - return true; - }); + await expectRegistryRejection( + Promise.all([first, second]), + 'session_surface_capacity' + ); expect(registry.stats().frozenSnapshotBytes).toBe(0); const recovered = await registry.redeemCatalogCursor({ @@ -505,77 +513,38 @@ describe('SessionSurfaceCursorRegistry', () => { request: { limit: 2 }, }); - await expect( - registry.redeemCatalogCursor({ - token, - scopeKey: 'scope-b', - epoch: 'epoch-1', - revision: 'revision-1', - request: { limit: 2 }, - loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { + const mismatchCases = [ + { + overrides: { scopeKey: 'scope-b' }, code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - return true; - }); - - await expect( - registry.redeemCatalogCursor({ - token, - scopeKey: 'scope-a', - epoch: 'epoch-2', - revision: 'revision-1', - request: { limit: 2 }, - loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { + }, + { + overrides: { epoch: 'epoch-2' }, code: 'session_surface_snapshot_changed', - retryable: false, - message: 'session surface snapshot changed', - }); - return true; - }); - - await expect( - registry.redeemCatalogCursor({ - token, - scopeKey: 'scope-a', - epoch: 'epoch-1', - revision: 'revision-2', - request: { limit: 2 }, - loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { + }, + { + overrides: { revision: 'revision-2' }, code: 'session_surface_snapshot_changed', - retryable: false, - message: 'session surface snapshot changed', - }); - return true; - }); - - await expect( - registry.redeemCatalogCursor({ - token, - scopeKey: 'scope-a', - epoch: 'epoch-1', - revision: 'revision-1', - request: { limit: 3 }, - loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { + }, + { + overrides: { request: { limit: 3 } }, code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - return true; - }); + }, + ] as const; + for (const { overrides, code } of mismatchCases) { + await expectRegistryRejection( + registry.redeemCatalogCursor({ + token, + scopeKey: 'scope-a', + epoch: 'epoch-1', + revision: 'revision-1', + request: { limit: 2 }, + loader: async () => ({ page: [] }), + ...overrides, + }), + code + ); + } }); it('binds history cursors to real snapshot entries and passes stored history state to the loader', async () => { @@ -638,71 +607,42 @@ describe('SessionSurfaceCursorRegistry', () => { }) ).resolves.toEqual({ items: ['h1', 'h2'] }); - await expect( + await expectRegistryRejection( registry.assertSnapshotToken({ token: snapshotToken, locatorDigest: 'locator-b', transcriptFingerprint: 'fingerprint-a', - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - return true; - }); + }), + 'session_surface_cursor_invalid' + ); - await expect( - registry.redeemHistoryCursor({ - token: historyToken, - locatorDigest: 'locator-b', - transcriptFingerprint: 'fingerprint-a', - request: { direction: 'forward', limit: 2 }, - loader: async () => ({ items: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { + const mismatchCases = [ + { + overrides: { locatorDigest: 'locator-b' }, code: 'session_surface_snapshot_changed', - retryable: false, - message: 'session surface snapshot changed', - }); - return true; - }); - - await expect( - registry.redeemHistoryCursor({ - token: historyToken, - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-b', - request: { direction: 'forward', limit: 2 }, - loader: async () => ({ items: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { + }, + { + overrides: { transcriptFingerprint: 'fingerprint-b' }, code: 'session_surface_snapshot_changed', - retryable: false, - message: 'session surface snapshot changed', - }); - return true; - }); - - await expect( - registry.redeemHistoryCursor({ - token: historyToken, - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - request: { direction: 'forward', limit: 1 }, - loader: async () => ({ items: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { + }, + { + overrides: { request: { direction: 'forward', limit: 1 } }, code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - return true; - }); + }, + ] as const; + for (const { overrides, code } of mismatchCases) { + await expectRegistryRejection( + registry.redeemHistoryCursor({ + token: historyToken, + locatorDigest: 'locator-a', + transcriptFingerprint: 'fingerprint-a', + request: { direction: 'forward', limit: 2 }, + loader: async () => ({ items: [] }), + ...overrides, + }), + code + ); + } const noFrozenSnapshotToken = registry.issueSnapshotToken({ chainId: 'chain-a', @@ -798,33 +738,19 @@ describe('SessionSurfaceCursorRegistry', () => { input: unknown ) => Promise; - expect(() => - issueCatalogCursorUnsafe.call(registry, { - chainId: 'chain-a', - scopeKey: 'scope-a', - epoch: 'epoch-1', - revision: 'revision-1', - boundary: new Map([['after', 'message-1']]), - request: { limit: 1 }, - }) - ).toThrowError(SessionSurfaceCursorRegistryError); - try { - issueCatalogCursorUnsafe.call(registry, { - chainId: 'chain-a', - scopeKey: 'scope-a', - epoch: 'epoch-1', - revision: 'revision-1', - boundary: new Map([['after', 'message-1']]), - request: { limit: 1 }, - }); - } catch (error) { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - expect(String(error)).not.toContain('Map'); - } + expectRegistryThrow( + () => + issueCatalogCursorUnsafe.call(registry, { + chainId: 'chain-a', + scopeKey: 'scope-a', + epoch: 'epoch-1', + revision: 'revision-1', + boundary: new Map([['after', 'message-1']]), + request: { limit: 1 }, + }), + 'session_surface_cursor_invalid', + (error) => expect(String(error)).not.toContain('Map') + ); const catalogToken = registry.issueCatalogCursor({ chainId: 'chain-b', @@ -837,7 +763,7 @@ describe('SessionSurfaceCursorRegistry', () => { const circularRequest: { self?: unknown } = {}; circularRequest.self = circularRequest; - await expect( + await expectRegistryRejection( redeemCatalogCursorUnsafe.call(registry, { token: catalogToken, scopeKey: 'scope-a', @@ -845,41 +771,25 @@ describe('SessionSurfaceCursorRegistry', () => { revision: 'revision-1', request: circularRequest, loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - expect(String(error)).not.toContain('self'); - return true; - }); + }), + 'session_surface_cursor_invalid', + (error) => expect(String(error)).not.toContain('self') + ); - expect(() => - issueSnapshotTokenUnsafe.call(registry, { - chainId: 'chain-c', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - frozenSnapshot: { count: 1n }, - }) - ).toThrowError(SessionSurfaceCursorRegistryError); - try { - issueSnapshotTokenUnsafe.call(registry, { - chainId: 'chain-c', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - frozenSnapshot: { count: 1n }, - }); - } catch (error) { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - expect(String(error)).not.toContain('count'); - expect(String(error)).not.toContain('1n'); - } + expectRegistryThrow( + () => + issueSnapshotTokenUnsafe.call(registry, { + chainId: 'chain-c', + locatorDigest: 'locator-a', + transcriptFingerprint: 'fingerprint-a', + frozenSnapshot: { count: 1n }, + }), + 'session_surface_cursor_invalid', + (error) => { + expect(String(error)).not.toContain('count'); + expect(String(error)).not.toContain('1n'); + } + ); const snapshotToken = registry.issueSnapshotToken({ chainId: 'chain-d', @@ -896,23 +806,17 @@ describe('SessionSurfaceCursorRegistry', () => { request: { direction: 'forward', limit: 2 }, }); - await expect( + await expectRegistryRejection( redeemHistoryCursorUnsafe.call(registry, { token: historyToken, locatorDigest: 'locator-d', transcriptFingerprint: 'fingerprint-d', request: { direction: 'forward', limit: 2 }, loader: async () => new Map([['items', ['h1']]]), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - expect(String(error)).not.toContain('Map'); - return true; - }); + }), + 'session_surface_cursor_invalid', + (error) => expect(String(error)).not.toContain('Map') + ); await expect( registry.redeemHistoryCursor({ @@ -951,32 +855,18 @@ describe('SessionSurfaceCursorRegistry', () => { const entryCountBeforeExpiredIssue = registry.stats().entryCount; clock.advance(11); - expect(() => - registry.issueHistoryCursor({ - chainId: 'chain-expired', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - nextSequence: 9, - snapshotToken: expiringSnapshotToken, - request: { direction: 'forward' }, - }) - ).toThrowError(SessionSurfaceCursorRegistryError); - try { - registry.issueHistoryCursor({ - chainId: 'chain-expired', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - nextSequence: 9, - snapshotToken: expiringSnapshotToken, - request: { direction: 'forward' }, - }); - } catch (error) { - expectRegistryError(error, { - code: 'session_surface_snapshot_changed', - retryable: false, - message: 'session surface snapshot changed', - }); - } + expectRegistryThrow( + () => + registry.issueHistoryCursor({ + chainId: 'chain-expired', + locatorDigest: 'locator-a', + transcriptFingerprint: 'fingerprint-a', + nextSequence: 9, + snapshotToken: expiringSnapshotToken, + request: { direction: 'forward' }, + }), + 'session_surface_snapshot_changed' + ); expect(registry.stats().entryCount).toBe(entryCountBeforeExpiredIssue - 1); const wrongKindToken = registry.issueCatalogCursor({ @@ -989,62 +879,34 @@ describe('SessionSurfaceCursorRegistry', () => { }); const entryCountBeforeWrongKindIssue = registry.stats().entryCount; - expect(() => - registry.issueHistoryCursor({ - chainId: 'chain-wrong-kind', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - nextSequence: 3, - snapshotToken: wrongKindToken, - request: { direction: 'forward' }, - }) - ).toThrowError(SessionSurfaceCursorRegistryError); - try { - registry.issueHistoryCursor({ - chainId: 'chain-wrong-kind', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - nextSequence: 3, - snapshotToken: wrongKindToken, - request: { direction: 'forward' }, - }); - } catch (error) { - expectRegistryError(error, { - code: 'session_surface_snapshot_changed', - retryable: false, - message: 'session surface snapshot changed', - }); - } + expectRegistryThrow( + () => + registry.issueHistoryCursor({ + chainId: 'chain-wrong-kind', + locatorDigest: 'locator-a', + transcriptFingerprint: 'fingerprint-a', + nextSequence: 3, + snapshotToken: wrongKindToken, + request: { direction: 'forward' }, + }), + 'session_surface_snapshot_changed' + ); expect(registry.stats().entryCount).toBe(entryCountBeforeWrongKindIssue); const entryCountBeforeMissingIssue = registry.stats().entryCount; - expect(() => - registry.issueHistoryCursor({ - chainId: 'chain-missing', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - nextSequence: 4, - snapshotToken: 'session-surface-snapshot:missing', - request: { direction: 'forward' }, - }) - ).toThrowError(SessionSurfaceCursorRegistryError); - try { - registry.issueHistoryCursor({ - chainId: 'chain-missing', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - nextSequence: 4, - snapshotToken: 'session-surface-snapshot:missing', - request: { direction: 'forward' }, - }); - } catch (error) { - expectRegistryError(error, { - code: 'session_surface_snapshot_changed', - retryable: false, - message: 'session surface snapshot changed', - }); - } + expectRegistryThrow( + () => + registry.issueHistoryCursor({ + chainId: 'chain-missing', + locatorDigest: 'locator-a', + transcriptFingerprint: 'fingerprint-a', + nextSequence: 4, + snapshotToken: 'session-surface-snapshot:missing', + request: { direction: 'forward' }, + }), + 'session_surface_snapshot_changed' + ); expect(registry.stats().entryCount).toBe(entryCountBeforeMissingIssue); const mismatchedSnapshotToken = registry.issueSnapshotToken({ @@ -1055,32 +917,18 @@ describe('SessionSurfaceCursorRegistry', () => { }); const entryCountBeforeMismatchIssue = registry.stats().entryCount; - expect(() => - registry.issueHistoryCursor({ - chainId: 'chain-history', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - nextSequence: 5, - snapshotToken: mismatchedSnapshotToken, - request: { direction: 'forward' }, - }) - ).toThrowError(SessionSurfaceCursorRegistryError); - try { - registry.issueHistoryCursor({ - chainId: 'chain-history', - locatorDigest: 'locator-a', - transcriptFingerprint: 'fingerprint-a', - nextSequence: 5, - snapshotToken: mismatchedSnapshotToken, - request: { direction: 'forward' }, - }); - } catch (error) { - expectRegistryError(error, { - code: 'session_surface_snapshot_changed', - retryable: false, - message: 'session surface snapshot changed', - }); - } + expectRegistryThrow( + () => + registry.issueHistoryCursor({ + chainId: 'chain-history', + locatorDigest: 'locator-a', + transcriptFingerprint: 'fingerprint-a', + nextSequence: 5, + snapshotToken: mismatchedSnapshotToken, + request: { direction: 'forward' }, + }), + 'session_surface_snapshot_changed' + ); expect(registry.stats().entryCount).toBe(entryCountBeforeMismatchIssue); }); @@ -1220,42 +1068,22 @@ describe('SessionSurfaceCursorRegistry', () => { revision: 'revision-1', request: { limit: 1 }, loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - return true; - }); + }), + 'session_surface_cursor_invalid' + ); - expect(() => - registry.issueCatalogCursor({ - chainId: 'chain-d', - scopeKey: 'scope-d', - epoch: 'epoch-1', - revision: 'revision-1', - boundary: null, - request: { limit: 1 }, - }) - ).toThrowError(SessionSurfaceCursorRegistryError); - try { - registry.issueCatalogCursor({ - chainId: 'chain-d', - scopeKey: 'scope-d', - epoch: 'epoch-1', - revision: 'revision-1', - boundary: null, - request: { limit: 1 }, - }); - } catch (error) { - expectRegistryError(error, { - code: 'session_surface_capacity', - retryable: true, - message: 'session surface cursor capacity exceeded', - }); - } + expectRegistryThrow( + () => + registry.issueCatalogCursor({ + chainId: 'chain-d', + scopeKey: 'scope-d', + epoch: 'epoch-1', + revision: 'revision-1', + boundary: null, + request: { limit: 1 }, + }), + 'session_surface_capacity' + ); }); it('reclaims frozen snapshot bytes from completed chains before failing capacity', async () => { @@ -1287,20 +1115,14 @@ describe('SessionSurfaceCursorRegistry', () => { Buffer.byteLength(JSON.stringify({ values: [2] }), 'utf8') ); - await expect( + await expectRegistryRejection( registry.assertSnapshotToken({ token: firstToken, locatorDigest: 'locator-a', transcriptFingerprint: 'fingerprint-a', - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - return true; - }); + }), + 'session_surface_cursor_invalid' + ); expect(() => registry.issueSnapshotToken({ @@ -1358,29 +1180,17 @@ describe('SessionSurfaceCursorRegistry', () => { this: SessionSurfaceCursorRegistry, input: unknown ) => string; - expect(() => - issueSnapshotTokenUnsafe.call(registry, { - chainId: 'chain-b', - locatorDigest: 'locator-b', - transcriptFingerprint: 'fingerprint-b', - frozenSnapshot: circular, - }) - ).toThrowError(SessionSurfaceCursorRegistryError); - try { - issueSnapshotTokenUnsafe.call(registry, { - chainId: 'chain-b', - locatorDigest: 'locator-b', - transcriptFingerprint: 'fingerprint-b', - frozenSnapshot: circular, - }); - } catch (error) { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - expect(String(error)).not.toContain('circular'); - } + expectRegistryThrow( + () => + issueSnapshotTokenUnsafe.call(registry, { + chainId: 'chain-b', + locatorDigest: 'locator-b', + transcriptFingerprint: 'fingerprint-b', + frozenSnapshot: circular, + }), + 'session_surface_cursor_invalid', + (error) => expect(String(error)).not.toContain('circular') + ); }); it('retries token collisions finitely and reports capacity when the source never yields a unique token', async () => { @@ -1450,7 +1260,7 @@ describe('SessionSurfaceCursorRegistry', () => { const closing = registry.close('/private/state/closing-reason'); await aborted.promise; - await expect( + await expectRegistryRejection( registry.redeemCatalogCursor({ token, scopeKey: 'scope-a', @@ -1458,25 +1268,13 @@ describe('SessionSurfaceCursorRegistry', () => { revision: 'revision-1', request: { limit: 1 }, loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_unavailable', - retryable: true, - message: 'session surface cursor registry is unavailable', - }); - return true; - }); - - await expect(redemption).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_unavailable', - retryable: true, - message: 'session surface cursor registry is unavailable', - }); - expect(String(error)).not.toContain('/private/state/closing-reason'); - return true; - }); + }), + 'session_surface_unavailable' + ); + + await expectRegistryRejection(redemption, 'session_surface_unavailable', (error) => + expect(String(error)).not.toContain('/private/state/closing-reason') + ); await expect(closing).resolves.toBeUndefined(); await expect(registry.close('ignored')).resolves.toBeUndefined(); expect(observedAbortReason).not.toContain('/private/state/closing-reason'); @@ -1503,7 +1301,7 @@ describe('SessionSurfaceCursorRegistry', () => { }); const second = new SessionSurfaceCursorRegistry(); - await expect( + await expectRegistryRejection( second.redeemCatalogCursor({ token, scopeKey: 'scope-a', @@ -1511,14 +1309,8 @@ describe('SessionSurfaceCursorRegistry', () => { revision: 'revision-1', request: { limit: 1 }, loader: async () => ({ page: [] }), - }) - ).rejects.toSatisfy((error: unknown) => { - expectRegistryError(error, { - code: 'session_surface_cursor_invalid', - retryable: false, - message: 'session surface cursor is invalid', - }); - return true; - }); + }), + 'session_surface_cursor_invalid' + ); }); }); From 407e95bd94ec23adf68a81be3e40440b9b45b1a2 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 14:23:08 +0800 Subject: [PATCH 18/59] refactor(test): unify provider admission ACP runner --- ...r-request-admission-acp-trajectory.test.ts | 3 +- ...-provider-admission-acp-trajectory.test.ts | 5 +- ...unner.ts => providerAdmissionAcpRunner.ts} | 131 +++++++-- .../weightedProviderAdmissionAcpRunner.ts | 260 ------------------ 4 files changed, 108 insertions(+), 291 deletions(-) rename packages/cli/tests/support/{providerRequestAdmissionAcpRunner.ts => providerAdmissionAcpRunner.ts} (62%) delete mode 100644 packages/cli/tests/support/weightedProviderAdmissionAcpRunner.ts diff --git a/packages/cli/tests/integration/real-api/provider-request-admission-acp-trajectory.test.ts b/packages/cli/tests/integration/real-api/provider-request-admission-acp-trajectory.test.ts index 7f1ee69e1..b97f5f9ee 100644 --- a/packages/cli/tests/integration/real-api/provider-request-admission-acp-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/provider-request-admission-acp-trajectory.test.ts @@ -25,7 +25,7 @@ const models = isRealApiTestEnabled() : []; const runner = path.resolve( import.meta.dirname, - '../../support/providerRequestAdmissionAcpRunner.ts' + '../../support/providerAdmissionAcpRunner.ts' ); const cliEntry = path.resolve(import.meta.dirname, '../../../dist/blade.js'); @@ -95,6 +95,7 @@ describe ); const encoded = Buffer.from( JSON.stringify({ + scenario: 'queued', cliEntry, workspace, home, diff --git a/packages/cli/tests/integration/real-api/weighted-provider-admission-acp-trajectory.test.ts b/packages/cli/tests/integration/real-api/weighted-provider-admission-acp-trajectory.test.ts index 4cb2dad77..a139e15d6 100644 --- a/packages/cli/tests/integration/real-api/weighted-provider-admission-acp-trajectory.test.ts +++ b/packages/cli/tests/integration/real-api/weighted-provider-admission-acp-trajectory.test.ts @@ -25,7 +25,7 @@ const models = isRealApiTestEnabled() : []; const runner = path.resolve( import.meta.dirname, - '../../support/weightedProviderAdmissionAcpRunner.ts' + '../../support/providerAdmissionAcpRunner.ts' ); const cliEntry = path.resolve(import.meta.dirname, '../../../dist/blade.js'); @@ -102,6 +102,7 @@ describe ); const encoded = Buffer.from( JSON.stringify({ + scenario: 'pending_bytes_rejected', cliEntry, workspace, home, @@ -120,7 +121,7 @@ describe cwd: path.resolve(import.meta.dirname, '../../..'), env: { ...process.env, - BLADE_WEIGHTED_ADMISSION_ACP_INPUT: encoded, + BLADE_PROVIDER_ADMISSION_ACP_INPUT: encoded, }, timeout: 180_000, maxBuffer: 1024 * 1024, diff --git a/packages/cli/tests/support/providerRequestAdmissionAcpRunner.ts b/packages/cli/tests/support/providerAdmissionAcpRunner.ts similarity index 62% rename from packages/cli/tests/support/providerRequestAdmissionAcpRunner.ts rename to packages/cli/tests/support/providerAdmissionAcpRunner.ts index 12fff21c2..4b000cd98 100644 --- a/packages/cli/tests/support/providerRequestAdmissionAcpRunner.ts +++ b/packages/cli/tests/support/providerAdmissionAcpRunner.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { access, readFile } from 'node:fs/promises'; import { Readable, Writable } from 'node:stream'; import * as acp from '@agentclientprotocol/sdk'; @@ -7,6 +7,7 @@ import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClie import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; interface RunnerInput { + scenario: keyof typeof SCENARIOS; cliEntry: string; workspace: string; home: string; @@ -23,6 +24,77 @@ function loadInput(): RunnerInput { return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; } +type PromptOutcome = + | { kind: 'result'; result: acp.PromptResponse } + | { kind: 'error'; error: unknown }; + +interface ScenarioContext { + connection: acp.ClientSideConnection; + input: RunnerInput; + primary: acp.PromptResponse; + secondary: PromptOutcome; + secondarySessionId: string; +} + +const SCENARIOS = { + queued: { + label: 'ACP Provider admission', + secondaryPrompt: (input: RunnerInput) => + `Reply with exactly ${input.secondaryMarker} and no other text.`, + matchesMetadata: (value: Record) => value.phase === 'queued', + metadataMessage: 'secondary Session did not project Provider admission queue', + verify: async ({ primary, secondary }: ScenarioContext) => { + if ( + primary.stopReason !== 'end_turn' || + secondary.kind !== 'result' || + secondary.result.stopReason !== 'end_turn' + ) { + const secondaryReason = + secondary.kind === 'result' ? secondary.result.stopReason : 'rejected'; + throw new Error( + `unexpected stop reasons: ${primary.stopReason}/${secondaryReason}` + ); + } + }, + requiresSecondaryMarker: true, + secondaryRejected: false, + }, + pending_bytes_rejected: { + label: 'Weighted ACP admission', + secondaryPrompt: (input: RunnerInput) => + `This request must be rejected before Provider traffic. ${input.secondaryMarker}`, + matchesMetadata: (value: Record) => + value.phase === 'rejected' && + value.resource === 'pending_bytes' && + value.reason === 'queue_full', + metadataMessage: 'secondary Session did not project pending-byte rejection', + verify: async ({ + connection, + input, + primary, + secondary, + secondarySessionId, + }: ScenarioContext) => { + if (primary.stopReason !== 'end_turn') { + throw new Error(`unexpected primary stop reason: ${primary.stopReason}`); + } + if (secondary.kind !== 'error') { + throw new Error( + `secondary unexpectedly completed: ${secondary.result.stopReason}` + ); + } + await connection.loadSession({ + sessionId: secondarySessionId, + cwd: input.workspace, + mcpServers: [], + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + }, + requiresSecondaryMarker: false, + secondaryRejected: true, + }, +} as const; + function agentText(client: ChildBackedRecordingAcpClient, sessionId: string): string { return client.sessionUpdates .filter((notification) => notification.sessionId === sessionId) @@ -54,6 +126,8 @@ function admissionMetadata( } async function run(input: RunnerInput) { + const scenario = SCENARIOS[input.scenario]; + if (!scenario) throw new Error(`Unknown admission scenario: ${input.scenario}`); const child = spawn(process.execPath, [input.cliEntry, '--acp'], { cwd: input.workspace, env: { @@ -68,7 +142,7 @@ async function run(input: RunnerInput) { }); if (!child.stdin || !child.stdout) { child.kill('SIGKILL'); - throw new Error('ACP Provider admission stdio was unavailable'); + throw new Error(`${scenario.label} stdio was unavailable`); } let stderr = ''; child.stderr?.on('data', (chunk: Buffer | string) => { @@ -115,7 +189,7 @@ async function run(input: RunnerInput) { } catch { return false; } - }, 'ACP primary request did not reach the Provider hold barrier'); + }, `${scenario.label} primary request did not reach the Provider hold barrier`); const secondary = await connection.newSession({ cwd: input.workspace, @@ -126,15 +200,15 @@ async function run(input: RunnerInput) { sessionId: secondarySessionId, modeId: 'yolo', }); - const secondaryPrompt = connection.prompt({ - sessionId: secondarySessionId, - prompt: [ - { - type: 'text', - text: `Reply with exactly ${input.secondaryMarker} and no other text.`, - }, - ], - }); + const secondaryPrompt = connection + .prompt({ + sessionId: secondarySessionId, + prompt: [{ type: 'text', text: scenario.secondaryPrompt(input) }], + }) + .then( + (result) => ({ kind: 'result' as const, result }), + (error: unknown) => ({ kind: 'error' as const, error }) + ); await waitFor( () => @@ -143,40 +217,40 @@ async function run(input: RunnerInput) { value !== null && typeof value === 'object' && !Array.isArray(value) && - (value as Record).phase === 'queued' + scenario.matchesMetadata(value as Record) ), - 'ACP secondary Session did not project Provider admission queue' + `${scenario.label} ${scenario.metadataMessage}` ); const [primaryResult, secondaryResult] = await Promise.all([ primaryPrompt, secondaryPrompt, ]); - if ( - primaryResult.stopReason !== 'end_turn' || - secondaryResult.stopReason !== 'end_turn' - ) { - throw new Error( - `Unexpected ACP Provider admission stop reasons: ${primaryResult.stopReason}/${secondaryResult.stopReason}` - ); - } + await scenario.verify({ + connection, + input, + primary: primaryResult, + secondary: secondaryResult, + secondarySessionId, + }); const metadata = admissionMetadata(client, secondarySessionId); if (!metadata.includes(null)) { - throw new Error('ACP Provider admission metadata was not cleared'); + throw new Error(`${scenario.label} metadata was not cleared`); } const primaryText = agentText(client, primarySessionId); const secondaryText = agentText(client, secondarySessionId); if ( !primaryText.includes(input.primaryMarker) || - !secondaryText.includes(input.secondaryMarker) + (scenario.requiresSecondaryMarker && + !secondaryText.includes(input.secondaryMarker)) ) { - throw new Error('ACP Provider admission Sessions did not finish independently'); + throw new Error(`${scenario.label} Sessions did not finish independently`); } if ( primaryText.includes('providerAdmission') || secondaryText.includes('providerAdmission') ) { - throw new Error('ACP Provider admission metadata polluted assistant text'); + throw new Error(`${scenario.label} metadata polluted assistant text`); } const [primaryTranscript, secondaryTranscript] = await Promise.all([ readFile(findSessionTranscript(input.storageRoot, primarySessionId), 'utf8'), @@ -191,7 +265,7 @@ async function run(input: RunnerInput) { serialized, ]) { if (value.includes(input.secret)) { - throw new Error('ACP Provider admission evidence exposed credentials'); + throw new Error(`${scenario.label} evidence exposed credentials`); } } @@ -200,7 +274,7 @@ async function run(input: RunnerInput) { await connection.closed.catch(() => undefined); if (exit.signal || exit.code !== 0) { throw new Error( - `ACP Provider admission exited ${ + `${scenario.label} exited ${ exit.code ?? exit.signal }: ${stderr.replaceAll(input.secret, '[redacted]')}` ); @@ -210,6 +284,7 @@ async function run(input: RunnerInput) { primarySessionId, secondarySessionId, metadata, + secondaryRejected: scenario.secondaryRejected, output: serialized.slice(-256_000), processes: client.releasedProcesses, }; diff --git a/packages/cli/tests/support/weightedProviderAdmissionAcpRunner.ts b/packages/cli/tests/support/weightedProviderAdmissionAcpRunner.ts deleted file mode 100644 index 7df5ed1d9..000000000 --- a/packages/cli/tests/support/weightedProviderAdmissionAcpRunner.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { type ChildProcess, spawn } from 'node:child_process'; -import { access, readFile } from 'node:fs/promises'; -import { Readable, Writable } from 'node:stream'; -import * as acp from '@agentclientprotocol/sdk'; -import { findSessionTranscript } from '../integration/real-api/sessionForkTrajectoryHarness.js'; -import { ChildBackedRecordingAcpClient } from './acp/ChildBackedRecordingAcpClient.js'; -import { waitForCondition as waitFor, waitForChildExit } from './asyncTestUtils.js'; - -interface RunnerInput { - cliEntry: string; - workspace: string; - home: string; - storageRoot: string; - barrierPath: string; - primaryMarker: string; - secondaryMarker: string; - secret: string; -} - -function loadInput(): RunnerInput { - const encoded = process.env.BLADE_WEIGHTED_ADMISSION_ACP_INPUT; - if (!encoded) throw new Error('Missing BLADE_WEIGHTED_ADMISSION_ACP_INPUT'); - return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as RunnerInput; -} - -function agentText(client: ChildBackedRecordingAcpClient, sessionId: string): string { - return client.sessionUpdates - .filter((notification) => notification.sessionId === sessionId) - .flatMap((notification) => - notification.update.sessionUpdate === 'agent_message_chunk' && - notification.update.content.type === 'text' - ? [notification.update.content.text] - : [] - ) - .join(''); -} - -function admissionMetadata( - client: ChildBackedRecordingAcpClient, - sessionId: string -): unknown[] { - return client.sessionUpdates - .filter( - (notification) => - notification.sessionId === sessionId && - notification.update.sessionUpdate === 'session_info_update' && - Object.hasOwn(notification.update._meta ?? {}, 'blade/providerAdmission') - ) - .map((notification) => - notification.update.sessionUpdate === 'session_info_update' - ? (notification.update._meta?.['blade/providerAdmission'] ?? null) - : null - ); -} - -async function run(input: RunnerInput) { - const child = spawn(process.execPath, [input.cliEntry, '--acp'], { - cwd: input.workspace, - env: { - ...process.env, - HOME: input.home, - BLADE_STORAGE_ROOT: input.storageRoot, - BLADE_AUTO_MEMORY: '0', - BLADE_TELEMETRY_DISABLED: '1', - TERM: 'xterm-256color', - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - if (!child.stdin || !child.stdout) { - child.kill('SIGKILL'); - throw new Error('Weighted ACP admission stdio was unavailable'); - } - let stderr = ''; - child.stderr?.on('data', (chunk: Buffer | string) => { - stderr = `${stderr}${chunk.toString()}`.slice(-64_000); - }); - const client = new ChildBackedRecordingAcpClient(); - const connection = new acp.ClientSideConnection( - () => client, - acp.ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as unknown as ReadableStream - ) - ); - let primarySessionId = ''; - let secondarySessionId = ''; - try { - await connection.initialize({ - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: { terminal: true }, - }); - const primary = await connection.newSession({ - cwd: input.workspace, - mcpServers: [], - }); - primarySessionId = primary.sessionId; - await connection.setSessionMode({ - sessionId: primarySessionId, - modeId: 'yolo', - }); - const primaryPrompt = connection.prompt({ - sessionId: primarySessionId, - prompt: [ - { - type: 'text', - text: `Reply with exactly ${input.primaryMarker} and no other text.`, - }, - ], - }); - - await waitFor(async () => { - try { - await access(input.barrierPath); - return true; - } catch { - return false; - } - }, 'Weighted ACP primary request did not reach the Provider hold barrier'); - - const secondary = await connection.newSession({ - cwd: input.workspace, - mcpServers: [], - }); - secondarySessionId = secondary.sessionId; - await connection.setSessionMode({ - sessionId: secondarySessionId, - modeId: 'yolo', - }); - const secondaryOutcome = connection - .prompt({ - sessionId: secondarySessionId, - prompt: [ - { - type: 'text', - text: - 'This request must be rejected before Provider traffic. ' + - input.secondaryMarker, - }, - ], - }) - .then( - (result) => ({ kind: 'result' as const, result }), - (error: unknown) => ({ kind: 'error' as const, error }) - ); - - await waitFor( - () => - admissionMetadata(client, secondarySessionId).some( - (value) => - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - (value as Record).phase === 'rejected' && - (value as Record).resource === 'pending_bytes' && - (value as Record).reason === 'queue_full' - ), - 'Weighted ACP secondary Session did not project pending-byte rejection' - ); - const [primaryResult, secondaryResult] = await Promise.all([ - primaryPrompt, - secondaryOutcome, - ]); - if (primaryResult.stopReason !== 'end_turn') { - throw new Error( - `Unexpected weighted ACP primary stop reason: ${primaryResult.stopReason}` - ); - } - if (secondaryResult.kind !== 'error') { - throw new Error( - `Weighted ACP secondary unexpectedly completed: ${secondaryResult.result.stopReason}` - ); - } - await connection.loadSession({ - sessionId: secondarySessionId, - cwd: input.workspace, - mcpServers: [], - }); - await new Promise((resolve) => setTimeout(resolve, 500)); - - const metadata = admissionMetadata(client, secondarySessionId); - if (!metadata.includes(null)) { - throw new Error('Weighted ACP admission metadata was not cleared'); - } - const primaryText = agentText(client, primarySessionId); - const secondaryText = agentText(client, secondarySessionId); - if (!primaryText.includes(input.primaryMarker)) { - throw new Error('Weighted ACP primary Session did not finish independently'); - } - if ( - primaryText.includes('providerAdmission') || - secondaryText.includes('providerAdmission') - ) { - throw new Error('Weighted ACP admission metadata polluted assistant text'); - } - const [primaryTranscript, secondaryTranscript] = await Promise.all([ - readFile(findSessionTranscript(input.storageRoot, primarySessionId), 'utf8'), - readFile(findSessionTranscript(input.storageRoot, secondarySessionId), 'utf8'), - ]); - const serialized = JSON.stringify(client.sessionUpdates); - for (const value of [ - primaryText, - secondaryText, - primaryTranscript, - secondaryTranscript, - serialized, - ]) { - if (value.includes(input.secret)) { - throw new Error('Weighted ACP admission evidence exposed credentials'); - } - } - - child.kill('SIGTERM'); - const exit = await waitForChildExit(child); - await connection.closed.catch(() => undefined); - if (exit.signal || exit.code !== 0) { - throw new Error( - `Weighted ACP admission exited ${ - exit.code ?? exit.signal - }: ${stderr.replaceAll(input.secret, '[redacted]')}` - ); - } - return { - success: true, - primarySessionId, - secondarySessionId, - metadata, - secondaryRejected: true, - output: serialized.slice(-256_000), - processes: client.releasedProcesses, - }; - } finally { - await client.close().catch(() => undefined); - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); - await waitForChildExit(child, 10_000).catch(() => undefined); - } - } -} - -async function main(): Promise { - const input = loadInput(); - try { - process.stdout.write(JSON.stringify(await run(input))); - } catch (error) { - process.stdout.write( - JSON.stringify({ - success: false, - error: (error instanceof Error ? error.message : String(error)).replaceAll( - input.secret, - '[redacted]' - ), - }) - ); - process.exitCode = 1; - } -} - -if (import.meta.main) { - await main(); -} From 7f8c454ed0fdeb95c038e04082a39e3f70b9d291 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 14:37:01 +0800 Subject: [PATCH 19/59] refactor(test): share real API GUI fixture launcher --- .../support/launch-mcp-completion-gui.ts | 132 +++----------- .../tests/support/launch-mcp-content-gui.ts | 133 +++----------- .../support/launch-mcp-dynamic-catalog-gui.ts | 140 +++------------ .../support/launch-mcp-elicitation-gui.ts | 121 +++---------- .../support/launch-mcp-instructions-gui.ts | 143 +++------------ .../tests/support/launch-mcp-logging-gui.ts | 132 +++----------- .../tests/support/launch-mcp-progress-gui.ts | 127 +++----------- .../tests/support/launch-mcp-recovery-gui.ts | 150 ++++------------ .../support/launch-mcp-roots-sampling-gui.ts | 140 +++------------ .../cli/tests/support/launch-mcp-tasks-gui.ts | 163 +++++------------- .../support/launch-mcp-tool-result-gui.ts | 135 +++------------ .../tests/support/launchRealApiGuiFixture.ts | 129 ++++++++++++++ 12 files changed, 424 insertions(+), 1221 deletions(-) create mode 100644 packages/cli/tests/support/launchRealApiGuiFixture.ts diff --git a/packages/cli/tests/support/launch-mcp-completion-gui.ts b/packages/cli/tests/support/launch-mcp-completion-gui.ts index db49daf7d..344423473 100644 --- a/packages/cli/tests/support/launch-mcp-completion-gui.ts +++ b/packages/cli/tests/support/launch-mcp-completion-gui.ts @@ -1,113 +1,31 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4332'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-completion-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const pidFile = path.join(root, 'pids'); -const traceFile = path.join(root, 'mcp-trace.jsonl'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-mcp-completion-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - completion: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_COMPLETION_NAMESPACE: 'PRIMARY', - MCP_COMPLETION_PID_FILE: pidFile, - MCP_COMPLETION_TRACE_FILE: traceFile, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-completion-gui.ts', + defaultPort: 4332, + readme: '# MCP completion GUI fixture\n', + configure: ({ root }) => { + const pidFile = path.join(root, 'pids'); + const traceFile = path.join(root, 'mcp-trace.jsonl'); + return { + config: { + mcpServers: { + completion: { + type: 'stdio', + command: process.execPath, + args: [path.resolve(import.meta.dirname, 'fake-mcp-completion-server.mjs')], + env: { + MCP_COMPLETION_NAMESPACE: 'PRIMARY', + MCP_COMPLETION_PID_FILE: pidFile, + MCP_COMPLETION_TRACE_FILE: traceFile, + }, + timeout: 15_000, + idleTimeout: 5_000, }, - timeout: 15_000, - idleTimeout: 5_000, }, }, - }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# MCP completion GUI fixture\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - pidFile, - traceFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { pidFile, traceFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-content-gui.ts b/packages/cli/tests/support/launch-mcp-content-gui.ts index 83ff4c31e..882528e31 100644 --- a/packages/cli/tests/support/launch-mcp-content-gui.ts +++ b/packages/cli/tests/support/launch-mcp-content-gui.ts @@ -1,115 +1,30 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4327'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-content-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const pidFile = path.join(root, 'mcp.pid'); -const traceFile = path.join(root, 'mcp-trace.jsonl'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-mcp-content-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - content: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_CONTENT_PID_FILE: pidFile, - MCP_CONTENT_TRACE_FILE: traceFile, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-content-gui.ts', + defaultPort: 4327, + readme: '# MCP resources and prompts GUI fixture\n', + configure: ({ root }) => { + const pidFile = path.join(root, 'mcp.pid'); + const traceFile = path.join(root, 'mcp-trace.jsonl'); + return { + config: { + mcpServers: { + content: { + type: 'stdio', + command: process.execPath, + args: [path.resolve(import.meta.dirname, 'fake-mcp-content-server.mjs')], + env: { + MCP_CONTENT_PID_FILE: pidFile, + MCP_CONTENT_TRACE_FILE: traceFile, + }, + timeout: 15_000, + idleTimeout: 4_000, }, - timeout: 15_000, - idleTimeout: 4_000, }, }, - }, - null, - 2 - )}\n` -); -await writeFile( - path.join(workspace, 'README.md'), - '# MCP resources and prompts GUI fixture\n' -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - pidFile, - traceFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { pidFile, traceFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-dynamic-catalog-gui.ts b/packages/cli/tests/support/launch-mcp-dynamic-catalog-gui.ts index fa9a052e1..582b86b80 100644 --- a/packages/cli/tests/support/launch-mcp-dynamic-catalog-gui.ts +++ b/packages/cli/tests/support/launch-mcp-dynamic-catalog-gui.ts @@ -1,120 +1,32 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4326'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error( - 'Usage: bun launch-mcp-dynamic-catalog-gui.ts [port]' - ); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const pidFile = path.join(root, 'mcp.pid'); -const traceFile = path.join(root, 'mcp-trace.jsonl'); -const fakeServer = path.resolve( - import.meta.dirname, - 'fake-mcp-dynamic-catalog-server.mjs' -); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - dynamic: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_DYNAMIC_PID_FILE: pidFile, - MCP_DYNAMIC_TRACE_FILE: traceFile, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-dynamic-catalog-gui.ts', + defaultPort: 4326, + readme: '# Dynamic MCP catalog GUI fixture\n', + configure: ({ root }) => { + const pidFile = path.join(root, 'mcp.pid'); + const traceFile = path.join(root, 'mcp-trace.jsonl'); + return { + config: { + mcpServers: { + dynamic: { + type: 'stdio', + command: process.execPath, + args: [ + path.resolve(import.meta.dirname, 'fake-mcp-dynamic-catalog-server.mjs'), + ], + env: { + MCP_DYNAMIC_PID_FILE: pidFile, + MCP_DYNAMIC_TRACE_FILE: traceFile, + }, + timeout: 15_000, + idleTimeout: 4_000, }, - timeout: 15_000, - idleTimeout: 4_000, }, }, - }, - null, - 2 - )}\n` -); -await writeFile( - path.join(workspace, 'README.md'), - '# Dynamic MCP catalog GUI fixture\n' -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - pidFile, - traceFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { pidFile, traceFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-elicitation-gui.ts b/packages/cli/tests/support/launch-mcp-elicitation-gui.ts index 7922cb03e..d4c8b1a60 100644 --- a/packages/cli/tests/support/launch-mcp-elicitation-gui.ts +++ b/packages/cli/tests/support/launch-mcp-elicitation-gui.ts @@ -1,107 +1,26 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4322'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-elicitation-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const pidFile = path.join(root, 'mcp.pid'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-mcp-elicitation-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - elicitation: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_ELICITATION_PID_FILE: pidFile, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-elicitation-gui.ts', + defaultPort: 4322, + readme: '# MCP elicitation fixture\n', + configure: ({ root }) => { + const pidFile = path.join(root, 'mcp.pid'); + return { + config: { + mcpServers: { + elicitation: { + type: 'stdio', + command: process.execPath, + args: [ + path.resolve(import.meta.dirname, 'fake-mcp-elicitation-server.mjs'), + ], + env: { MCP_ELICITATION_PID_FILE: pidFile }, }, }, }, - }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# MCP elicitation fixture\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - pidFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { pidFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-instructions-gui.ts b/packages/cli/tests/support/launch-mcp-instructions-gui.ts index 66c0b1119..1920fcc71 100644 --- a/packages/cli/tests/support/launch-mcp-instructions-gui.ts +++ b/packages/cli/tests/support/launch-mcp-instructions-gui.ts @@ -1,121 +1,34 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4331'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-instructions-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const generationFile = path.join(root, 'generation'); -const pidFile = path.join(root, 'pids'); -const traceFile = path.join(root, 'mcp-trace.jsonl'); -const fakeServer = path.resolve( - import.meta.dirname, - 'fake-mcp-instructions-server.mjs' -); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - instructions: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_INSTRUCTIONS_GENERATION_FILE: generationFile, - MCP_INSTRUCTIONS_PID_FILE: pidFile, - MCP_INSTRUCTIONS_TRACE_FILE: traceFile, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-instructions-gui.ts', + defaultPort: 4331, + readme: '# MCP server instructions GUI fixture\n', + configure: ({ root }) => { + const generationFile = path.join(root, 'generation'); + const pidFile = path.join(root, 'pids'); + const traceFile = path.join(root, 'mcp-trace.jsonl'); + return { + config: { + mcpServers: { + instructions: { + type: 'stdio', + command: process.execPath, + args: [ + path.resolve(import.meta.dirname, 'fake-mcp-instructions-server.mjs'), + ], + env: { + MCP_INSTRUCTIONS_GENERATION_FILE: generationFile, + MCP_INSTRUCTIONS_PID_FILE: pidFile, + MCP_INSTRUCTIONS_TRACE_FILE: traceFile, + }, + timeout: 15_000, + idleTimeout: 4_000, }, - timeout: 15_000, - idleTimeout: 4_000, }, }, - }, - null, - 2 - )}\n` -); -await writeFile( - path.join(workspace, 'README.md'), - '# MCP server instructions GUI fixture\n' -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - generationFile, - pidFile, - traceFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { generationFile, pidFile, traceFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-logging-gui.ts b/packages/cli/tests/support/launch-mcp-logging-gui.ts index 6f6e6444a..9e3c1e42e 100644 --- a/packages/cli/tests/support/launch-mcp-logging-gui.ts +++ b/packages/cli/tests/support/launch-mcp-logging-gui.ts @@ -1,115 +1,31 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4330'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-logging-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const pidFile = path.join(root, 'mcp.pid'); -const traceFile = path.join(root, 'mcp-trace.jsonl'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-mcp-logging-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - logging: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_LOGGING_PID_FILE: pidFile, - MCP_LOGGING_TRACE_FILE: traceFile, - }, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-logging-gui.ts', + defaultPort: 4330, + readme: '# MCP logging GUI fixture\n', + configure: ({ root }) => { + const pidFile = path.join(root, 'mcp.pid'); + const traceFile = path.join(root, 'mcp-trace.jsonl'); + return { + config: { + mcpServers: { logging: { - level: 'warning', + type: 'stdio', + command: process.execPath, + args: [path.resolve(import.meta.dirname, 'fake-mcp-logging-server.mjs')], + env: { + MCP_LOGGING_PID_FILE: pidFile, + MCP_LOGGING_TRACE_FILE: traceFile, + }, + logging: { level: 'warning' }, + timeout: 15_000, + idleTimeout: 4_000, }, - timeout: 15_000, - idleTimeout: 4_000, }, }, - }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# MCP logging GUI fixture\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - pidFile, - traceFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { pidFile, traceFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-progress-gui.ts b/packages/cli/tests/support/launch-mcp-progress-gui.ts index 15b1e16db..a304000e6 100644 --- a/packages/cli/tests/support/launch-mcp-progress-gui.ts +++ b/packages/cli/tests/support/launch-mcp-progress-gui.ts @@ -1,110 +1,29 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4324'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-progress-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const pidFile = path.join(root, 'mcp.pid'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-mcp-lifecycle-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - lifecycle: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_LIFECYCLE_PID_FILE: pidFile, - MCP_LIFECYCLE_PROGRESS_DELAY_MS: '3000', +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-progress-gui.ts', + defaultPort: 4324, + readme: '# MCP progress fixture\n', + configure: ({ root }) => { + const pidFile = path.join(root, 'mcp.pid'); + return { + config: { + mcpServers: { + lifecycle: { + type: 'stdio', + command: process.execPath, + args: [path.resolve(import.meta.dirname, 'fake-mcp-lifecycle-server.mjs')], + env: { + MCP_LIFECYCLE_PID_FILE: pidFile, + MCP_LIFECYCLE_PROGRESS_DELAY_MS: '3000', + }, + timeout: 15_000, + idleTimeout: 4_000, }, - timeout: 15_000, - idleTimeout: 4_000, }, }, - }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# MCP progress fixture\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - pidFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { pidFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-recovery-gui.ts b/packages/cli/tests/support/launch-mcp-recovery-gui.ts index 278542458..935792fbe 100644 --- a/packages/cli/tests/support/launch-mcp-recovery-gui.ts +++ b/packages/cli/tests/support/launch-mcp-recovery-gui.ts @@ -1,125 +1,39 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4328'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-recovery-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const generationFile = path.join(root, 'generation'); -const pidFile = path.join(root, 'pids'); -const traceFile = path.join(root, 'mcp-trace.jsonl'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-mcp-recovery-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - recovery: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_RECOVERY_GENERATION_FILE: generationFile, - MCP_RECOVERY_PID_FILE: pidFile, - MCP_RECOVERY_TRACE_FILE: traceFile, - }, - timeout: 15_000, - idleTimeout: 4_000, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-recovery-gui.ts', + defaultPort: 4328, + readme: '# MCP connection recovery GUI fixture\n', + configure: ({ root }) => { + const generationFile = path.join(root, 'generation'); + const pidFile = path.join(root, 'pids'); + const traceFile = path.join(root, 'mcp-trace.jsonl'); + return { + config: { + mcpServers: { recovery: { - maxAttempts: 3, - initialDelayMs: 20, - maxDelayMs: 50, - jitterRatio: 0, - terminalErrorThreshold: 1, + type: 'stdio', + command: process.execPath, + args: [path.resolve(import.meta.dirname, 'fake-mcp-recovery-server.mjs')], + env: { + MCP_RECOVERY_GENERATION_FILE: generationFile, + MCP_RECOVERY_PID_FILE: pidFile, + MCP_RECOVERY_TRACE_FILE: traceFile, + }, + timeout: 15_000, + idleTimeout: 4_000, + recovery: { + maxAttempts: 3, + initialDelayMs: 20, + maxDelayMs: 50, + jitterRatio: 0, + terminalErrorThreshold: 1, + }, }, }, }, - }, - null, - 2 - )}\n` -); -await writeFile( - path.join(workspace, 'README.md'), - '# MCP connection recovery GUI fixture\n' -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - generationFile, - pidFile, - traceFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { generationFile, pidFile, traceFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-roots-sampling-gui.ts b/packages/cli/tests/support/launch-mcp-roots-sampling-gui.ts index fb9c88041..76d104283 100644 --- a/packages/cli/tests/support/launch-mcp-roots-sampling-gui.ts +++ b/packages/cli/tests/support/launch-mcp-roots-sampling-gui.ts @@ -1,119 +1,33 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4323'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-roots-sampling-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project with space'); -const storage = path.join(root, 'storage'); -const pidFile = path.join(root, 'mcp.pid'); -const fakeServer = path.resolve( - import.meta.dirname, - 'fake-mcp-roots-sampling-server.mjs' -); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - sampler: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_ROOTS_SAMPLING_PID_FILE: pidFile, - }, - sampling: { - enabled: true, - maxTokens: 64, - maxRequestsPerToolCall: 1, - maxInputBytes: 16_384, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-roots-sampling-gui.ts', + defaultPort: 4323, + workspaceName: 'project with space', + readme: '# MCP roots and sampling fixture\n', + configure: ({ root }) => { + const pidFile = path.join(root, 'mcp.pid'); + return { + config: { + mcpServers: { + sampler: { + type: 'stdio', + command: process.execPath, + args: [ + path.resolve(import.meta.dirname, 'fake-mcp-roots-sampling-server.mjs'), + ], + env: { MCP_ROOTS_SAMPLING_PID_FILE: pidFile }, + sampling: { + enabled: true, + maxTokens: 64, + maxRequestsPerToolCall: 1, + maxInputBytes: 16_384, + }, }, }, }, - }, - null, - 2 - )}\n` -); -await writeFile( - path.join(workspace, 'README.md'), - '# MCP roots and sampling fixture\n' -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - pidFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { pidFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-tasks-gui.ts b/packages/cli/tests/support/launch-mcp-tasks-gui.ts index c540b472f..f0d7ed2fc 100644 --- a/packages/cli/tests/support/launch-mcp-tasks-gui.ts +++ b/packages/cli/tests/support/launch-mcp-tasks-gui.ts @@ -1,130 +1,47 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4333'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-tasks-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const stateFile = path.join(root, 'task-state.json'); -const pidFile = path.join(root, 'pids'); -const traceFile = path.join(root, 'mcp-trace.jsonl'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-mcp-task-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - tasks: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_TASK_STATE_FILE: stateFile, - MCP_TASK_PID_FILE: pidFile, - MCP_TASK_TRACE_FILE: traceFile, - MCP_TASK_NAMESPACE: 'PRIMARY', - }, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-tasks-gui.ts', + defaultPort: 4333, + readme: '# MCP Tasks GUI fixture\n', + configure: ({ root }) => { + const stateFile = path.join(root, 'task-state.json'); + const pidFile = path.join(root, 'pids'); + const traceFile = path.join(root, 'mcp-trace.jsonl'); + return { + config: { + mcpServers: { tasks: { - enabled: true, - defaultTtlMs: 60_000, - pollIntervalMs: 100, - maxTasksPerSession: 8, - maxLifetimeMs: 60_000, - }, - recovery: { - maxAttempts: 5, - initialDelayMs: 20, - maxDelayMs: 50, - jitterRatio: 0, - terminalErrorThreshold: 1, + type: 'stdio', + command: process.execPath, + args: [path.resolve(import.meta.dirname, 'fake-mcp-task-server.mjs')], + env: { + MCP_TASK_STATE_FILE: stateFile, + MCP_TASK_PID_FILE: pidFile, + MCP_TASK_TRACE_FILE: traceFile, + MCP_TASK_NAMESPACE: 'PRIMARY', + }, + tasks: { + enabled: true, + defaultTtlMs: 60_000, + pollIntervalMs: 100, + maxTasksPerSession: 8, + maxLifetimeMs: 60_000, + }, + recovery: { + maxAttempts: 5, + initialDelayMs: 20, + maxDelayMs: 50, + jitterRatio: 0, + terminalErrorThreshold: 1, + }, + timeout: 15_000, + idleTimeout: 5_000, }, - timeout: 15_000, - idleTimeout: 5_000, }, }, - }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# MCP Tasks GUI fixture\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - stateFile, - pidFile, - traceFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { stateFile, pidFile, traceFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launch-mcp-tool-result-gui.ts b/packages/cli/tests/support/launch-mcp-tool-result-gui.ts index c63caa6fd..42f20f7dc 100644 --- a/packages/cli/tests/support/launch-mcp-tool-result-gui.ts +++ b/packages/cli/tests/support/launch-mcp-tool-result-gui.ts @@ -1,115 +1,32 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4329'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-mcp-tool-result-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const pidFile = path.join(root, 'mcp.pid'); -const traceFile = path.join(root, 'mcp-trace.jsonl'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-mcp-tool-result-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - results: { - type: 'stdio', - command: process.execPath, - args: [fakeServer], - env: { - MCP_TOOL_RESULT_PID_FILE: pidFile, - MCP_TOOL_RESULT_TRACE_FILE: traceFile, +await launchRealApiGuiFixture({ + scriptName: 'launch-mcp-tool-result-gui.ts', + defaultPort: 4329, + readme: '# MCP tool result safety GUI fixture\n', + configure: ({ root }) => { + const pidFile = path.join(root, 'mcp.pid'); + const traceFile = path.join(root, 'mcp-trace.jsonl'); + return { + config: { + mcpServers: { + results: { + type: 'stdio', + command: process.execPath, + args: [ + path.resolve(import.meta.dirname, 'fake-mcp-tool-result-server.mjs'), + ], + env: { + MCP_TOOL_RESULT_PID_FILE: pidFile, + MCP_TOOL_RESULT_TRACE_FILE: traceFile, + }, + timeout: 15_000, + idleTimeout: 4_000, }, - timeout: 15_000, - idleTimeout: 4_000, }, }, - }, - null, - 2 - )}\n` -); -await writeFile( - path.join(workspace, 'README.md'), - '# MCP tool result safety GUI fixture\n' -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - pidFile, - traceFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { pidFile, traceFile }, + }; + }, }); diff --git a/packages/cli/tests/support/launchRealApiGuiFixture.ts b/packages/cli/tests/support/launchRealApiGuiFixture.ts new file mode 100644 index 000000000..3448b45a8 --- /dev/null +++ b/packages/cli/tests/support/launchRealApiGuiFixture.ts @@ -0,0 +1,129 @@ +import { execFile, spawn } from 'node:child_process'; +import { mkdir, realpath, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; +import { + buildRealApiRuntimeConfig, + resolveForkQualificationModels, +} from '../integration/real-api/testConfig.js'; + +const execFileAsync = promisify(execFile); + +type RuntimeConfig = ReturnType; + +export interface RealApiGuiFixtureContext { + root: string; + home: string; + workspace: string; + storage: string; + port: number; + runtimeConfig: RuntimeConfig; +} + +interface RealApiGuiFixtureOptions { + scriptName: string; + defaultPort: number; + readme: string; + workspaceName?: string; + configure(context: RealApiGuiFixtureContext): { + config: Record; + metadata?: Record; + }; +} + +export async function launchRealApiGuiFixture( + options: RealApiGuiFixtureOptions +): Promise { + const [root, rawPort = String(options.defaultPort)] = process.argv.slice(2); + if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { + throw new Error(`Usage: bun ${options.scriptName} [port]`); + } + + const home = path.join(root, 'home'); + const workspace = path.join(root, options.workspaceName ?? 'project'); + const storage = path.join(root, 'storage'); + const port = Number(rawPort); + for (const [name, value] of Object.entries( + materializeRealApiEnvironment(process.env) + )) { + if (value !== undefined) process.env[name] = value; + } + process.env.REAL_API_TEST = '1'; + process.env.HOME = home; + process.env.BLADE_STORAGE_ROOT = storage; + + const model = resolveForkQualificationModels(process.env).find( + (candidate) => + candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' + ); + if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); + const runtimeConfig = buildRealApiRuntimeConfig(model); + const context = { root, home, workspace, storage, port, runtimeConfig }; + const fixture = options.configure(context); + + await Promise.all([ + mkdir(path.join(home, '.blade'), { recursive: true }), + mkdir(workspace, { recursive: true }), + ]); + await Promise.all([ + writeFile( + path.join(home, '.blade', 'config.json'), + `${JSON.stringify( + { + currentModelId: runtimeConfig.currentModelId, + models: runtimeConfig.models, + modelProviders: runtimeConfig.modelProviders, + ...fixture.config, + }, + null, + 2 + )}\n` + ), + writeFile(path.join(workspace, 'README.md'), options.readme), + ]); + await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); + await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { + cwd: workspace, + }); + await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { + cwd: workspace, + }); + await execFileAsync('git', ['add', '.'], { cwd: workspace }); + await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); + + const canonicalWorkspace = await realpath(workspace); + const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); + const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { + cwd: canonicalWorkspace, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.pipe(process.stdout); + child.stderr.pipe(process.stderr); + process.stdout.write( + `${JSON.stringify({ + home, + workspace: canonicalWorkspace, + storage, + port, + ...fixture.metadata, + })}\n` + ); + + const stop = () => child.kill('SIGTERM'); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code && code !== 0) { + reject(new Error(`Blade server exited with code ${code}`)); + } else if (signal && signal !== 'SIGTERM') { + reject(new Error(`Blade server exited from signal ${signal}`)); + } else { + resolve(); + } + }); + }); +} From 60fcd53d38e0e0d26f7cf025269b166a7d30d60a Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 14:57:31 +0800 Subject: [PATCH 20/59] refactor(test): reuse GUI fixture lifecycle --- .../tests/support/launch-apply-patch-gui.ts | 111 ++-------- .../tests/support/launch-auto-verify-gui.ts | 181 ++++------------ .../tests/support/launch-code-review-gui.ts | 162 +++----------- .../support/launch-goal-verification-gui.ts | 172 ++++----------- .../launch-independent-verification-gui.ts | 200 ++++++------------ packages/cli/tests/support/launch-lsp-gui.ts | 164 ++++---------- .../support/launch-permission-mode-gui.ts | 119 ++--------- .../support/launch-session-archive-gui.ts | 101 +-------- .../support/launch-session-export-gui.ts | 110 ++-------- .../support/launch-structured-output-gui.ts | 108 +--------- .../tests/support/launchRealApiGuiFixture.ts | 54 +++-- 11 files changed, 322 insertions(+), 1160 deletions(-) diff --git a/packages/cli/tests/support/launch-apply-patch-gui.ts b/packages/cli/tests/support/launch-apply-patch-gui.ts index 03e8b1c65..34c72afd8 100644 --- a/packages/cli/tests/support/launch-apply-patch-gui.ts +++ b/packages/cli/tests/support/launch-apply-patch-gui.ts @@ -1,103 +1,20 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const execFileAsync = promisify(execFile); -const [root, rawPort = '4321'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-apply-patch-gui.ts [port]'); -} - -const port = Number(rawPort); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -async function write(relativeRoot: string, relativePath: string, content: string) { - const filePath = path.join(relativeRoot, relativePath); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, 'utf8'); -} - -await write( - home, - '.blade/config.json', - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - }, - null, - 2 - )}\n` -); -await write(workspace, 'first.ts', 'export const first = false;\n'); -await write(workspace, 'second.ts', 'export const second = false;\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, +await launchRealApiGuiFixture({ + scriptName: 'launch-apply-patch-gui.ts', + defaultPort: 4321, + configure: () => ({ config: {} }), + setup: async ({ workspace }) => { + await Promise.all([ + writeFile(path.join(workspace, 'first.ts'), 'export const first = false;\n'), + writeFile(path.join(workspace, 'second.ts'), 'export const second = false;\n'), + ]); + }, + afterCommit: async ({ canonicalWorkspace }) => ({ first: path.join(canonicalWorkspace, 'first.ts'), second: path.join(canonicalWorkspace, 'second.ts'), added: path.join(canonicalWorkspace, 'added.ts'), - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + }), }); diff --git a/packages/cli/tests/support/launch-auto-verify-gui.ts b/packages/cli/tests/support/launch-auto-verify-gui.ts index f68d1ba1a..3db5a9d9c 100644 --- a/packages/cli/tests/support/launch-auto-verify-gui.ts +++ b/packages/cli/tests/support/launch-auto-verify-gui.ts @@ -1,139 +1,46 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { PermissionMode } from '../../src/config/types.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; - -const execFileAsync = promisify(execFile); -const [root, rawPort = '4319'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-auto-verify-gui.ts [port]'); -} - -const port = Number(rawPort); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const marker = path.join(root, 'auto-verify-marker'); -const beforeTarget = path.join(workspace, 'before-trust.ts'); -const afterTarget = path.join(workspace, 'after-trust.ts'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -async function write(relativeRoot: string, relativePath: string, content: string) { - const filePath = path.join(relativeRoot, relativePath); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, 'utf8'); -} - -await write( - home, - '.blade/config.json', - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - permissionMode: PermissionMode.YOLO, - }, - null, - 2 - )}\n` -); -await write( - workspace, - 'package.json', - `${JSON.stringify( - { - name: 'blade-auto-verify-gui', - version: '1.0.0', - private: true, - scripts: { - 'type-check': 'node type-check.cjs', - }, - }, - null, - 2 - )}\n` -); -await write(workspace, 'tsconfig.json', '{}\n'); -await write( - workspace, - 'type-check.cjs', - [ - `require('node:fs').writeFileSync(${JSON.stringify( - marker - )}, process.env.BLADE_SESSION_ID || 'missing');`, - `process.stderr.write(${JSON.stringify( - `${afterTarget}(1,1): error TS9001: GUI_AUTO_VERIFY_SIGNAL\n` - )});`, - 'process.exitCode = 1;', - ].join('\n') -); - -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - marker, - beforeTarget, - afterTarget, - port, - })}\n` -); - -const stop = () => { - child.kill('SIGTERM'); -}; -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; + +await launchRealApiGuiFixture({ + scriptName: 'launch-auto-verify-gui.ts', + defaultPort: 4319, + configure: () => ({ config: { permissionMode: 'yolo' } }), + setup: async ({ root, workspace }) => { + const marker = path.join(root, 'auto-verify-marker'); + const afterTarget = path.join(workspace, 'after-trust.ts'); + await Promise.all([ + writeFile( + path.join(workspace, 'package.json'), + `${JSON.stringify( + { + name: 'blade-auto-verify-gui', + version: '1.0.0', + private: true, + scripts: { 'type-check': 'node type-check.cjs' }, + }, + null, + 2 + )}\n` + ), + writeFile(path.join(workspace, 'tsconfig.json'), '{}\n'), + writeFile( + path.join(workspace, 'type-check.cjs'), + [ + `require('node:fs').writeFileSync(${JSON.stringify( + marker + )}, process.env.BLADE_SESSION_ID || 'missing');`, + `process.stderr.write(${JSON.stringify( + `${afterTarget}(1,1): error TS9001: GUI_AUTO_VERIFY_SIGNAL\n` + )});`, + 'process.exitCode = 1;', + ].join('\n') + ), + ]); + }, + afterCommit: async ({ root, workspace }) => ({ + marker: path.join(root, 'auto-verify-marker'), + beforeTarget: path.join(workspace, 'before-trust.ts'), + afterTarget: path.join(workspace, 'after-trust.ts'), + }), }); diff --git a/packages/cli/tests/support/launch-code-review-gui.ts b/packages/cli/tests/support/launch-code-review-gui.ts index 6afb0362e..8e77f2e54 100644 --- a/packages/cli/tests/support/launch-code-review-gui.ts +++ b/packages/cli/tests/support/launch-code-review-gui.ts @@ -1,86 +1,11 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; const execFileAsync = promisify(execFile); -const [root, rawPort = '4341'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-code-review-gui.ts [port]'); -} - -const port = Number(rawPort); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; -process.env.BLADE_TELEMETRY_DISABLED = '1'; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(workspace, { recursive: true }), -]); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - permissionMode: 'default', - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - null, - 2 - )}\n` -); - -const target = path.join(workspace, 'authorization.ts'); -await writeFile( - target, - [ - 'export interface User {', - ' id: string;', - ' isAdmin: boolean;', - '}', - '', - 'export function isAuthorized(user: User, resourceOwnerId: string) {', - ' if (user.isAdmin) return true;', - ' return user.id === resourceOwnerId;', - '}', - '', - ].join('\n') -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -await writeFile( - target, +const authorized = (assignment: boolean) => [ 'export interface User {', ' id: string;', @@ -89,55 +14,36 @@ await writeFile( '', 'export function isAuthorized(user: User, resourceOwnerId: string) {', ' if (user.isAdmin) return true;', - ' return (user.id = resourceOwnerId);', + ` return ${assignment ? '(user.id = resourceOwnerId)' : 'user.id === resourceOwnerId'};`, '}', '', - ].join('\n') -); - -const canonicalWorkspace = await realpath(workspace); -const beforeContent = await readFile(target, 'utf8'); -const beforeStatus = ( - await execFileAsync('git', ['status', '--porcelain=v1'], { - cwd: canonicalWorkspace, - encoding: 'utf8', - }) -).stdout.trim(); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - target, - beforeContent, - beforeStatus, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + ].join('\n'); + +await launchRealApiGuiFixture({ + scriptName: 'launch-code-review-gui.ts', + defaultPort: 4341, + environment: { BLADE_TELEMETRY_DISABLED: '1' }, + configure: () => ({ + config: { + permissionMode: 'default', + hooks: { enabled: false }, + disableAllHooks: true, + mcpServers: {}, + }, + }), + setup: async ({ workspace }) => { + await writeFile(path.join(workspace, 'authorization.ts'), authorized(false)); + }, + afterCommit: async ({ canonicalWorkspace }) => { + const target = path.join(canonicalWorkspace, 'authorization.ts'); + await writeFile(target, authorized(true)); + const beforeContent = await readFile(target, 'utf8'); + const beforeStatus = ( + await execFileAsync('git', ['status', '--porcelain=v1'], { + cwd: canonicalWorkspace, + encoding: 'utf8', + }) + ).stdout.trim(); + return { target, beforeContent, beforeStatus }; + }, }); diff --git a/packages/cli/tests/support/launch-goal-verification-gui.ts b/packages/cli/tests/support/launch-goal-verification-gui.ts index d05ee53a4..f3b454371 100644 --- a/packages/cli/tests/support/launch-goal-verification-gui.ts +++ b/packages/cli/tests/support/launch-goal-verification-gui.ts @@ -1,136 +1,52 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { PermissionMode } from '../../src/config/types.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const execFileAsync = promisify(execFile); -const [root, rawPort = '4343'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-goal-verification-gui.ts [port]'); -} - -const port = Number(rawPort); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; -process.env.BLADE_TELEMETRY_DISABLED = '1'; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -async function write(relativePath: string, content: string): Promise { - const filePath = path.join(workspace, relativePath); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, 'utf8'); -} - -await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(workspace, { recursive: true }), -]); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - permissionMode: PermissionMode.YOLO, +await launchRealApiGuiFixture({ + scriptName: 'launch-goal-verification-gui.ts', + defaultPort: 4343, + environment: { BLADE_TELEMETRY_DISABLED: '1' }, + configure: ({ model }) => ({ + config: { + permissionMode: 'yolo', hooks: { enabled: false }, disableAllHooks: true, mcpServers: {}, }, - null, - 2 - )}\n` -); -await write( - 'package.json', - `${JSON.stringify( - { - name: 'blade-goal-verification-gui', - private: true, - scripts: { test: 'node --test goal.test.cjs' }, + metadata: { + model: model.model, + prompt: + '/goal Create goal-gui.txt with the exact content GOAL_GUI_VERIFIED. ' + + 'Read it back, run npm test, then call UpdateGoal complete.', }, - null, - 2 - )}\n` -); -await write( - 'goal.test.cjs', - [ - "const assert = require('node:assert/strict');", - "const fs = require('node:fs');", - "const test = require('node:test');", - '', - "test('verified goal output', () => {", - " assert.equal(fs.readFileSync('goal-gui.txt', 'utf8').trim(), 'GOAL_GUI_VERIFIED');", - '});', - '', - ].join('\n') -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); - -const canonicalWorkspace = await realpath(workspace); -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - port, - model: model.model, - prompt: - '/goal Create goal-gui.txt with the exact content GOAL_GUI_VERIFIED. ' + - 'Read it back, run npm test, then call UpdateGoal complete.', - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + }), + setup: async ({ workspace }) => { + await Promise.all([ + writeFile( + path.join(workspace, 'package.json'), + `${JSON.stringify( + { + name: 'blade-goal-verification-gui', + private: true, + scripts: { test: 'node --test goal.test.cjs' }, + }, + null, + 2 + )}\n` + ), + writeFile( + path.join(workspace, 'goal.test.cjs'), + [ + "const assert = require('node:assert/strict');", + "const fs = require('node:fs');", + "const test = require('node:test');", + '', + "test('verified goal output', () => {", + " assert.equal(fs.readFileSync('goal-gui.txt', 'utf8').trim(), 'GOAL_GUI_VERIFIED');", + '});', + '', + ].join('\n') + ), + ]); + }, }); diff --git a/packages/cli/tests/support/launch-independent-verification-gui.ts b/packages/cli/tests/support/launch-independent-verification-gui.ts index 5fe2bd70d..51a8ee0a2 100644 --- a/packages/cli/tests/support/launch-independent-verification-gui.ts +++ b/packages/cli/tests/support/launch-independent-verification-gui.ts @@ -1,144 +1,64 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { PermissionMode } from '../../src/config/types.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const execFileAsync = promisify(execFile); -const [root, rawPort = '4326'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error( - 'Usage: bun launch-independent-verification-gui.ts [port]' - ); -} - -const port = Number(rawPort); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; -process.env.BLADE_TELEMETRY_DISABLED = '1'; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -async function write(relativeRoot: string, relativePath: string, content: string) { - const filePath = path.join(relativeRoot, relativePath); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, 'utf8'); -} - -await write( - home, - '.blade/config.json', - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - permissionMode: PermissionMode.YOLO, - }, - null, - 2 - )}\n` -); -await write( - workspace, - 'package.json', - `${JSON.stringify( - { - name: 'blade-independent-verification-gui', - private: true, - type: 'module', - scripts: { test: 'node --test' }, +await launchRealApiGuiFixture({ + scriptName: 'launch-independent-verification-gui.ts', + defaultPort: 4326, + environment: { BLADE_TELEMETRY_DISABLED: '1' }, + configure: () => ({ + config: { permissionMode: 'yolo' }, + metadata: { + prompt: [ + 'Read src/alpha.js, src/beta.js, and src/gamma.js.', + 'Then call ApplyPatch exactly once to change each exported value from', + 'false to true. Do not modify package.json or the test.', + 'After ApplyPatch succeeds, return exactly WEB_INDEPENDENT_VERIFY_OK.', + ].join(' '), }, - null, - 2 - )}\n` -); -for (const name of ['alpha', 'beta', 'gamma']) { - await write(workspace, `src/${name}.js`, `export const ${name} = false;\n`); -} -await write( - workspace, - 'test/values.test.js', - [ - "import assert from 'node:assert/strict';", - "import test from 'node:test';", - "import { alpha } from '../src/alpha.js';", - "import { beta } from '../src/beta.js';", - "import { gamma } from '../src/gamma.js';", - '', - "test('all values are enabled', () => {", - ' assert.equal(alpha, true);', - ' assert.equal(beta, true);', - ' assert.equal(gamma, true);', - '});', - '', - ].join('\n') -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - port, - prompt: [ - 'Read src/alpha.js, src/beta.js, and src/gamma.js.', - 'Then call ApplyPatch exactly once to change each exported value from', - 'false to true. Do not modify package.json or the test.', - 'After ApplyPatch succeeds, return exactly WEB_INDEPENDENT_VERIFY_OK.', - ].join(' '), - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + }), + setup: async ({ workspace }) => { + await Promise.all([ + mkdir(path.join(workspace, 'src'), { recursive: true }), + mkdir(path.join(workspace, 'test'), { recursive: true }), + ]); + await Promise.all([ + writeFile( + path.join(workspace, 'package.json'), + `${JSON.stringify( + { + name: 'blade-independent-verification-gui', + private: true, + type: 'module', + scripts: { test: 'node --test' }, + }, + null, + 2 + )}\n` + ), + ...['alpha', 'beta', 'gamma'].map((name) => + writeFile( + path.join(workspace, `src/${name}.js`), + `export const ${name} = false;\n` + ) + ), + writeFile( + path.join(workspace, 'test/values.test.js'), + [ + "import assert from 'node:assert/strict';", + "import test from 'node:test';", + "import { alpha } from '../src/alpha.js';", + "import { beta } from '../src/beta.js';", + "import { gamma } from '../src/gamma.js';", + '', + "test('all values are enabled', () => {", + ' assert.equal(alpha, true);', + ' assert.equal(beta, true);', + ' assert.equal(gamma, true);', + '});', + '', + ].join('\n') + ), + ]); + }, }); diff --git a/packages/cli/tests/support/launch-lsp-gui.ts b/packages/cli/tests/support/launch-lsp-gui.ts index bac7a48cd..11fd9d45d 100644 --- a/packages/cli/tests/support/launch-lsp-gui.ts +++ b/packages/cli/tests/support/launch-lsp-gui.ts @@ -1,131 +1,41 @@ -import { execFile, spawn } from 'node:child_process'; import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; - -const execFileAsync = promisify(execFile); -const [root, rawPort = '4320'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-lsp-gui.ts [port]'); -} - -const port = Number(rawPort); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const source = path.join(workspace, 'source.ts'); -const diagnosticTarget = path.join(workspace, 'diagnostic.ts'); -const trace = path.join(root, 'lsp-trace.jsonl'); -const pidFile = path.join(root, 'lsp.pid'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-lsp-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -async function write(relativeRoot: string, relativePath: string, content: string) { - const filePath = path.join(relativeRoot, relativePath); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, 'utf8'); -} - -await write( - home, - '.blade/config.json', - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - }, - null, - 2 - )}\n` -); -await write( - workspace, - '.blade/config.json', - `${JSON.stringify( - { - lspServers: { - qualification: { - command: process.execPath, - args: [fakeServer], - extensionToLanguage: { '.ts': 'typescript' }, - env: { - LSP_TRACE_FILE: trace, - LSP_PID_FILE: pidFile, +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; + +await launchRealApiGuiFixture({ + scriptName: 'launch-lsp-gui.ts', + defaultPort: 4320, + configure: () => ({ config: {} }), + setup: async ({ root, workspace }) => { + const trace = path.join(root, 'lsp-trace.jsonl'); + const pidFile = path.join(root, 'lsp.pid'); + await mkdir(path.join(workspace, '.blade'), { recursive: true }); + await Promise.all([ + writeFile( + path.join(workspace, '.blade', 'config.json'), + `${JSON.stringify( + { + lspServers: { + qualification: { + command: process.execPath, + args: [path.resolve(import.meta.dirname, 'fake-lsp-server.mjs')], + extensionToLanguage: { '.ts': 'typescript' }, + env: { LSP_TRACE_FILE: trace, LSP_PID_FILE: pidFile }, + diagnosticWaitTimeout: 2_000, + }, + }, }, - diagnosticWaitTimeout: 2_000, - }, - }, - }, - null, - 2 - )}\n` -); -await write(workspace, 'source.ts', 'export const value = "ready";\n'); - -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - source: await realpath(source), - diagnosticTarget, - trace, - pidFile, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + null, + 2 + )}\n` + ), + writeFile(path.join(workspace, 'source.ts'), 'export const value = "ready";\n'), + ]); + }, + afterCommit: async ({ root, canonicalWorkspace }) => ({ + source: await realpath(path.join(canonicalWorkspace, 'source.ts')), + diagnosticTarget: path.join(canonicalWorkspace, 'diagnostic.ts'), + trace: path.join(root, 'lsp-trace.jsonl'), + pidFile: path.join(root, 'lsp.pid'), + }), }); diff --git a/packages/cli/tests/support/launch-permission-mode-gui.ts b/packages/cli/tests/support/launch-permission-mode-gui.ts index bb6feb19c..cd0c60c62 100644 --- a/packages/cli/tests/support/launch-permission-mode-gui.ts +++ b/packages/cli/tests/support/launch-permission-mode-gui.ts @@ -1,109 +1,26 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; import { SessionService } from '../../src/services/SessionService.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4339'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-permission-mode-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(workspace, { recursive: true }), -]); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, +await launchRealApiGuiFixture({ + scriptName: 'launch-permission-mode-gui.ts', + defaultPort: 4339, + readme: '# Permission mode GUI\n', + configure: () => ({ + config: { permissionMode: 'default', hooks: { enabled: false }, disableAllHooks: true, mcpServers: {}, }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# Permission mode GUI\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); -const sessionId = `permission-gui-${Date.now()}`; -await SessionService.createSessionMetadata(sessionId, canonicalWorkspace, { - title: 'Persistent YOLO Session', - taskStatus: 'completed', - selectedModelId: config.currentModelId, - permissionMode: 'yolo', -}); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - sessionId, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + }), + afterCommit: async ({ canonicalWorkspace, runtimeConfig }) => { + const sessionId = `permission-gui-${Date.now()}`; + await SessionService.createSessionMetadata(sessionId, canonicalWorkspace, { + title: 'Persistent YOLO Session', + taskStatus: 'completed', + selectedModelId: runtimeConfig.currentModelId, + permissionMode: 'yolo', + }); + return { sessionId }; + }, }); diff --git a/packages/cli/tests/support/launch-session-archive-gui.ts b/packages/cli/tests/support/launch-session-archive-gui.ts index 53fb2ac8a..e25404bc4 100644 --- a/packages/cli/tests/support/launch-session-archive-gui.ts +++ b/packages/cli/tests/support/launch-session-archive-gui.ts @@ -1,97 +1,14 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; - -const [root, rawPort = '4334'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-session-archive-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; + +await launchRealApiGuiFixture({ + scriptName: 'launch-session-archive-gui.ts', + defaultPort: 4334, + readme: '# Session Archive GUI fixture\n', + configure: () => ({ + config: { hooks: { enabled: false }, disableAllHooks: true, mcpServers: {}, }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# Session Archive GUI fixture\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + }), }); diff --git a/packages/cli/tests/support/launch-session-export-gui.ts b/packages/cli/tests/support/launch-session-export-gui.ts index 6b2a4d0ec..02eb02535 100644 --- a/packages/cli/tests/support/launch-session-export-gui.ts +++ b/packages/cli/tests/support/launch-session-export-gui.ts @@ -1,103 +1,25 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4335'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-session-export-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); const marker = `EXPORT_GUI_VISIBLE_${Date.now()}`; -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, +await launchRealApiGuiFixture({ + scriptName: 'launch-session-export-gui.ts', + defaultPort: 4335, + readme: '# Session Export GUI fixture\n', + configure: () => ({ + config: { hooks: { enabled: false }, disableAllHooks: true, mcpServers: {}, }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# Session Export GUI fixture\n'); -await writeFile( - path.join(workspace, 'evidence.txt'), - `${marker}\nsk-EXPORT_GUI_SECRET_1234567890\n/Users/gui-owner/private/file.txt\n` -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - marker, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { marker }, + }), + setup: async ({ workspace }) => { + await writeFile( + path.join(workspace, 'evidence.txt'), + `${marker}\nsk-EXPORT_GUI_SECRET_1234567890\n/Users/gui-owner/private/file.txt\n` + ); + }, }); diff --git a/packages/cli/tests/support/launch-structured-output-gui.ts b/packages/cli/tests/support/launch-structured-output-gui.ts index 5877c2c40..9f913bc91 100644 --- a/packages/cli/tests/support/launch-structured-output-gui.ts +++ b/packages/cli/tests/support/launch-structured-output-gui.ts @@ -1,105 +1,17 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const execFileAsync = promisify(execFile); -const [root, rawPort = '4342'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-structured-output-gui.ts [port]'); -} - -const port = Number(rawPort); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; -process.env.BLADE_TELEMETRY_DISABLED = '1'; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(workspace, { recursive: true }), -]); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, +await launchRealApiGuiFixture({ + scriptName: 'launch-structured-output-gui.ts', + defaultPort: 4342, + readme: '# Structured output GUI qualification\n', + environment: { BLADE_TELEMETRY_DISABLED: '1' }, + configure: ({ model }) => ({ + config: { permissionMode: 'autoEdit', hooks: { enabled: false }, disableAllHooks: true, mcpServers: {}, }, - null, - 2 - )}\n` -); -await writeFile( - path.join(workspace, 'README.md'), - '# Structured output GUI qualification\n' -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); - -const canonicalWorkspace = await realpath(workspace); -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - port, - model: model.model, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + metadata: { model: model.model }, + }), }); diff --git a/packages/cli/tests/support/launchRealApiGuiFixture.ts b/packages/cli/tests/support/launchRealApiGuiFixture.ts index 3448b45a8..ffcdbc2b8 100644 --- a/packages/cli/tests/support/launchRealApiGuiFixture.ts +++ b/packages/cli/tests/support/launchRealApiGuiFixture.ts @@ -6,6 +6,7 @@ import { materializeRealApiEnvironment } from '../../scripts/real-api-credential import { buildRealApiRuntimeConfig, resolveForkQualificationModels, + type TestModelConfig, } from '../integration/real-api/testConfig.js'; const execFileAsync = promisify(execFile); @@ -18,18 +19,28 @@ export interface RealApiGuiFixtureContext { workspace: string; storage: string; port: number; + model: TestModelConfig; runtimeConfig: RuntimeConfig; } +interface CommittedRealApiGuiFixtureContext extends RealApiGuiFixtureContext { + canonicalWorkspace: string; +} + interface RealApiGuiFixtureOptions { scriptName: string; defaultPort: number; - readme: string; + readme?: string; workspaceName?: string; + environment?: Readonly>; configure(context: RealApiGuiFixtureContext): { config: Record; metadata?: Record; }; + setup?(context: RealApiGuiFixtureContext): Promise; + afterCommit?( + context: CommittedRealApiGuiFixtureContext + ): Promise | undefined>; } export async function launchRealApiGuiFixture( @@ -52,6 +63,7 @@ export async function launchRealApiGuiFixture( process.env.REAL_API_TEST = '1'; process.env.HOME = home; process.env.BLADE_STORAGE_ROOT = storage; + Object.assign(process.env, options.environment); const model = resolveForkQualificationModels(process.env).find( (candidate) => @@ -59,29 +71,30 @@ export async function launchRealApiGuiFixture( ); if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); const runtimeConfig = buildRealApiRuntimeConfig(model); - const context = { root, home, workspace, storage, port, runtimeConfig }; + const context = { root, home, workspace, storage, port, model, runtimeConfig }; const fixture = options.configure(context); await Promise.all([ mkdir(path.join(home, '.blade'), { recursive: true }), mkdir(workspace, { recursive: true }), ]); - await Promise.all([ - writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: runtimeConfig.currentModelId, - models: runtimeConfig.models, - modelProviders: runtimeConfig.modelProviders, - ...fixture.config, - }, - null, - 2 - )}\n` - ), - writeFile(path.join(workspace, 'README.md'), options.readme), - ]); + await writeFile( + path.join(home, '.blade', 'config.json'), + `${JSON.stringify( + { + currentModelId: runtimeConfig.currentModelId, + models: runtimeConfig.models, + modelProviders: runtimeConfig.modelProviders, + ...fixture.config, + }, + null, + 2 + )}\n` + ); + if (options.readme !== undefined) { + await writeFile(path.join(workspace, 'README.md'), options.readme); + } + await options.setup?.(context); await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { cwd: workspace, @@ -93,6 +106,10 @@ export async function launchRealApiGuiFixture( await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); const canonicalWorkspace = await realpath(workspace); + const committedMetadata = await options.afterCommit?.({ + ...context, + canonicalWorkspace, + }); const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { cwd: canonicalWorkspace, @@ -108,6 +125,7 @@ export async function launchRealApiGuiFixture( storage, port, ...fixture.metadata, + ...committedMetadata, })}\n` ); From 33d899a636a4da7b9a17f2222549634e542cf596 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 15:28:32 +0800 Subject: [PATCH 21/59] refactor(test): share durable GUI fixture lifecycle --- .../support/launch-durable-interaction-gui.ts | 211 +++++------------- .../tests/support/launch-turn-recovery-gui.ts | 152 +++---------- 2 files changed, 93 insertions(+), 270 deletions(-) diff --git a/packages/cli/tests/support/launch-durable-interaction-gui.ts b/packages/cli/tests/support/launch-durable-interaction-gui.ts index 1255aa0b4..4999a8c5e 100644 --- a/packages/cli/tests/support/launch-durable-interaction-gui.ts +++ b/packages/cli/tests/support/launch-durable-interaction-gui.ts @@ -1,168 +1,71 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; import { PersistentStore } from '../../src/context/storage/PersistentStore.js'; import { SessionInteractionService } from '../../src/services/SessionInteractionService.js'; import { SessionService } from '../../src/services/SessionService.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4340'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error( - 'Usage: bun launch-durable-interaction-gui.ts [port]' - ); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); +const question = { + header: 'Channel', + question: 'Which release channel should Blade write?', + multiSelect: false, + options: [ + { label: 'Stable', description: 'Use the stable release channel' }, + { label: 'Canary', description: 'Use the early canary release channel' }, + ], +}; -await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(workspace, { recursive: true }), -]); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, +await launchRealApiGuiFixture({ + scriptName: 'launch-durable-interaction-gui.ts', + defaultPort: 4340, + readme: '# Durable interaction GUI\n', + configure: () => ({ + config: { permissionMode: 'default', hooks: { enabled: false }, disableAllHooks: true, mcpServers: {}, }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# Durable interaction GUI\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); - -const canonicalWorkspace = await realpath(workspace); -const sessionId = `interaction-gui-${Date.now()}`; -const target = path.join(canonicalWorkspace, 'gui-selected-channel.txt'); -await SessionService.createSessionMetadata(sessionId, canonicalWorkspace, { - title: 'Pending Channel Decision', - taskStatus: 'completed', - selectedModelId: config.currentModelId, - permissionMode: 'yolo', -}); -const store = new PersistentStore(canonicalWorkspace); -await store.saveMessage( - sessionId, - 'user', - [ - 'A Channel question will be recovered in the production Web UI.', - `After the recovered answer, call Write exactly once with file_path=${JSON.stringify( - target - )}.`, - 'Set content to the selected label followed by exactly one newline.', - 'That Write is the only allowed tool call. Never call AskUserQuestion again.', - 'Do not emit assistant text or end the turn before Write succeeds.', - 'After Write succeeds, reply exactly GUI_INTERACTION_RECOVERED.', - ].join(' ') -); -const toolCallId = await store.saveToolUse(sessionId, 'AskUserQuestion', { - questions: [ - { - header: 'Channel', - question: 'Which release channel should Blade write?', - multiSelect: false, - options: [ - { label: 'Stable', description: 'Use the stable release channel' }, - { label: 'Canary', description: 'Use the early canary release channel' }, - ], - }, - ], -}); -const request = await SessionInteractionService.request( - { - sessionId, - projectPath: canonicalWorkspace, - toolCallId, - toolName: 'AskUserQuestion', - }, - { - type: 'askUserQuestion', - message: 'Choose a release channel', - questions: [ + }), + afterCommit: async ({ canonicalWorkspace, runtimeConfig }) => { + const sessionId = `interaction-gui-${Date.now()}`; + const target = path.join(canonicalWorkspace, 'gui-selected-channel.txt'); + await SessionService.createSessionMetadata(sessionId, canonicalWorkspace, { + title: 'Pending Channel Decision', + taskStatus: 'completed', + selectedModelId: runtimeConfig.currentModelId, + permissionMode: 'yolo', + }); + const store = new PersistentStore(canonicalWorkspace); + await store.saveMessage( + sessionId, + 'user', + [ + 'A Channel question will be recovered in the production Web UI.', + `After the recovered answer, call Write exactly once with file_path=${JSON.stringify( + target + )}.`, + 'Set content to the selected label followed by exactly one newline.', + 'That Write is the only allowed tool call. Never call AskUserQuestion again.', + 'Do not emit assistant text or end the turn before Write succeeds.', + 'After Write succeeds, reply exactly GUI_INTERACTION_RECOVERED.', + ].join(' ') + ); + const toolCallId = await store.saveToolUse(sessionId, 'AskUserQuestion', { + questions: [question], + }); + const request = await SessionInteractionService.request( { - header: 'Channel', - question: 'Which release channel should Blade write?', - multiSelect: false, - options: [ - { label: 'Stable', description: 'Use the stable release channel' }, - { label: 'Canary', description: 'Use the early canary release channel' }, - ], + sessionId, + projectPath: canonicalWorkspace, + toolCallId, + toolName: 'AskUserQuestion', }, - ], - } -); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - sessionId, - requestId: request.requestId, - target, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + { + type: 'askUserQuestion', + message: 'Choose a release channel', + questions: [question], + } + ); + return { sessionId, requestId: request.requestId, target }; + }, }); diff --git a/packages/cli/tests/support/launch-turn-recovery-gui.ts b/packages/cli/tests/support/launch-turn-recovery-gui.ts index 07a835121..e6148d93c 100644 --- a/packages/cli/tests/support/launch-turn-recovery-gui.ts +++ b/packages/cli/tests/support/launch-turn-recovery-gui.ts @@ -1,127 +1,47 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; import { PermissionMode } from '../../src/config/types.js'; import { SessionService } from '../../src/services/SessionService.js'; import { ensureStoreInitialized, getState } from '../../src/store/vanilla.js'; import { seedRootTurnAutoResumeFixture } from '../integration/real-api/rootTurnAutoResumeFixture.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; +import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; -const [root, rawPort = '4341'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-turn-recovery-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(workspace, { recursive: true }), -]); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, +await launchRealApiGuiFixture({ + scriptName: 'launch-turn-recovery-gui.ts', + defaultPort: 4341, + readme: '# Turn recovery GUI\n', + configure: () => ({ + config: { permissionMode: 'yolo', hooks: { enabled: false }, disableAllHooks: true, mcpServers: {}, }, - null, - 2 - )}\n` -); -await ensureStoreInitialized(); -getState().config.actions.setConfig({ - ...config, - permissionMode: PermissionMode.YOLO, - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, -}); -await writeFile(path.join(workspace, 'README.md'), '# Turn recovery GUI\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); - -const canonicalWorkspace = await realpath(workspace); -const sessionId = `turn-recovery-gui-${Date.now()}`; -await SessionService.createSessionMetadata(sessionId, canonicalWorkspace, { - title: 'Recovery Review Required', - taskStatus: 'completed', - permissionMode: PermissionMode.YOLO, -}); -const fixture = await seedRootTurnAutoResumeFixture({ - workspace: canonicalWorkspace, - sessionId, - marker: 'GUI_RECOVERY_MARKER', -}); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - sessionId, - markerPath: fixture.markerPath, - expectedResponse: fixture.expectedResponse, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); + }), + setup: async ({ runtimeConfig }) => { + await ensureStoreInitialized(); + getState().config.actions.setConfig({ + ...runtimeConfig, + permissionMode: PermissionMode.YOLO, + hooks: { enabled: false }, + disableAllHooks: true, + mcpServers: {}, + }); + }, + afterCommit: async ({ canonicalWorkspace }) => { + const sessionId = `turn-recovery-gui-${Date.now()}`; + await SessionService.createSessionMetadata(sessionId, canonicalWorkspace, { + title: 'Recovery Review Required', + taskStatus: 'completed', + permissionMode: PermissionMode.YOLO, + }); + const fixture = await seedRootTurnAutoResumeFixture({ + workspace: canonicalWorkspace, + sessionId, + marker: 'GUI_RECOVERY_MARKER', + }); + return { + sessionId, + markerPath: fixture.markerPath, + expectedResponse: fixture.expectedResponse, + }; + }, }); From 3de88341682b9a5737f07ca19bda1d9f1805adf8 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 15:34:22 +0800 Subject: [PATCH 22/59] refactor(test): remove unused GUI launchers --- .../support/launch-action-stationarity-gui.ts | 226 ------------- .../tests/support/launch-apply-patch-gui.ts | 20 -- .../tests/support/launch-auto-verify-gui.ts | 46 --- .../tests/support/launch-code-review-gui.ts | 49 --- .../support/launch-durable-interaction-gui.ts | 71 ---- .../support/launch-goal-verification-gui.ts | 52 --- .../launch-independent-verification-gui.ts | 64 ---- packages/cli/tests/support/launch-lsp-gui.ts | 41 --- .../support/launch-mcp-completion-gui.ts | 31 -- .../tests/support/launch-mcp-content-gui.ts | 30 -- .../support/launch-mcp-dynamic-catalog-gui.ts | 32 -- .../support/launch-mcp-elicitation-gui.ts | 26 -- .../support/launch-mcp-instructions-gui.ts | 34 -- .../tests/support/launch-mcp-logging-gui.ts | 31 -- .../cli/tests/support/launch-mcp-oauth-gui.ts | 174 ---------- .../tests/support/launch-mcp-progress-gui.ts | 29 -- .../tests/support/launch-mcp-recovery-gui.ts | 39 --- .../support/launch-mcp-roots-sampling-gui.ts | 33 -- .../cli/tests/support/launch-mcp-tasks-gui.ts | 47 --- .../support/launch-mcp-tool-result-gui.ts | 32 -- .../support/launch-permission-mode-gui.ts | 26 -- .../support/launch-plugin-marketplace-gui.ts | 200 ------------ .../support/launch-reactive-compaction.ts | 253 --------------- .../support/launch-reasoning-effort-gui.ts | 305 ------------------ .../support/launch-session-archive-gui.ts | 14 - .../support/launch-session-export-gui.ts | 25 -- .../support/launch-structured-output-gui.ts | 17 - .../tests/support/launch-turn-recovery-gui.ts | 47 --- .../tests/support/launchRealApiGuiFixture.ts | 147 --------- 29 files changed, 2141 deletions(-) delete mode 100644 packages/cli/tests/support/launch-action-stationarity-gui.ts delete mode 100644 packages/cli/tests/support/launch-apply-patch-gui.ts delete mode 100644 packages/cli/tests/support/launch-auto-verify-gui.ts delete mode 100644 packages/cli/tests/support/launch-code-review-gui.ts delete mode 100644 packages/cli/tests/support/launch-durable-interaction-gui.ts delete mode 100644 packages/cli/tests/support/launch-goal-verification-gui.ts delete mode 100644 packages/cli/tests/support/launch-independent-verification-gui.ts delete mode 100644 packages/cli/tests/support/launch-lsp-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-completion-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-content-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-dynamic-catalog-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-elicitation-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-instructions-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-logging-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-oauth-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-progress-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-recovery-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-roots-sampling-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-tasks-gui.ts delete mode 100644 packages/cli/tests/support/launch-mcp-tool-result-gui.ts delete mode 100644 packages/cli/tests/support/launch-permission-mode-gui.ts delete mode 100644 packages/cli/tests/support/launch-plugin-marketplace-gui.ts delete mode 100644 packages/cli/tests/support/launch-reactive-compaction.ts delete mode 100644 packages/cli/tests/support/launch-reasoning-effort-gui.ts delete mode 100644 packages/cli/tests/support/launch-session-archive-gui.ts delete mode 100644 packages/cli/tests/support/launch-session-export-gui.ts delete mode 100644 packages/cli/tests/support/launch-structured-output-gui.ts delete mode 100644 packages/cli/tests/support/launch-turn-recovery-gui.ts delete mode 100644 packages/cli/tests/support/launchRealApiGuiFixture.ts diff --git a/packages/cli/tests/support/launch-action-stationarity-gui.ts b/packages/cli/tests/support/launch-action-stationarity-gui.ts deleted file mode 100644 index 7c166d56e..000000000 --- a/packages/cli/tests/support/launch-action-stationarity-gui.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { execFile, spawn } from 'node:child_process'; -import { createServer } from 'node:http'; -import type { AddressInfo } from 'node:net'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { PermissionMode } from '../../src/config/types.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; - -const [root, rawPort = '4346'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error( - 'Usage: bun launch-action-stationarity-gui.ts [port]' - ); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; -process.env.BLADE_TELEMETRY_DISABLED = '1'; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); - -async function readRequestBody(request: import('node:http').IncomingMessage) { - const chunks: Buffer[] = []; - for await (const chunk of request) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks); -} - -function upstreamUrl(baseUrl: string, requestUrl: string | undefined): URL { - const target = new URL(baseUrl); - const incoming = new URL(requestUrl ?? '/', 'http://127.0.0.1'); - target.pathname = `${target.pathname.replace(/\/$/, '')}/${incoming.pathname.replace( - /^\//, - '' - )}`; - target.search = incoming.search; - return target; -} - -async function startProxy(baseUrl: string) { - let requestCount = 0; - let correctionDelayCount = 0; - const server = createServer((request, response) => { - void (async () => { - const body = await readRequestBody(request); - const requestNumber = ++requestCount; - const isCorrectionRequest = body - .toString('utf8') - .includes('without observable progress'); - if (isCorrectionRequest) { - correctionDelayCount++; - process.stdout.write( - `[action-stationarity-proxy] correction request ${requestNumber} paused\n` - ); - await new Promise((resolve) => setTimeout(resolve, 15_000)); - } - - const headers = new Headers(); - for (const [name, value] of Object.entries(request.headers)) { - if (!value || name === 'host' || name === 'content-length') continue; - headers.set(name, Array.isArray(value) ? value.join(', ') : value); - } - const upstream = await fetch(upstreamUrl(baseUrl, request.url), { - method: request.method ?? 'POST', - headers, - body: request.method === 'GET' || request.method === 'HEAD' ? undefined : body, - redirect: 'manual', - }); - const responseHeaders: Record = {}; - upstream.headers.forEach((value, name) => { - if ( - ![ - 'connection', - 'content-encoding', - 'content-length', - 'transfer-encoding', - ].includes(name) - ) { - responseHeaders[name] = value; - } - }); - response.writeHead(upstream.status, responseHeaders); - if (upstream.body) { - const reader = upstream.body.getReader(); - for (;;) { - const chunk = await reader.read(); - if (chunk.done) break; - response.write(Buffer.from(chunk.value)); - } - } - response.end(); - })().catch((error: unknown) => { - if (response.headersSent) { - response.destroy(error instanceof Error ? error : undefined); - return; - } - response.writeHead(502, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ error: { type: 'proxy_error' } })); - }); - }); - - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - server.off('error', reject); - resolve(); - }); - }); - const address = server.address() as AddressInfo; - return { - baseURL: `http://127.0.0.1:${address.port}`, - counts: () => ({ requestCount, correctionDelayCount }), - close: async () => { - server.closeAllConnections(); - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - }, - }; -} - -const proxy = await startProxy(model.baseURL ?? 'https://api.deepseek.com'); -const config = buildRealApiRuntimeConfig({ ...model, baseURL: proxy.baseURL }); -await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(workspace, { recursive: true }), -]); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - permissionMode: PermissionMode.YOLO, - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - null, - 2 - )}\n` -); -await writeFile( - path.join(workspace, 'README.md'), - '# Stationarity qualification\n\nUNCHANGED_EXTERNAL_STATE\n' -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - port, - proxy: proxy.baseURL, - prompt: [ - 'This is a host recovery qualification.', - `Call Read with exactly ${JSON.stringify({ - file_path: path.join(canonicalWorkspace, 'README.md'), - })} once per assistant turn.`, - 'After every unchanged result, call the exact same Read again in the next turn.', - 'Do not stop voluntarily.', - 'Only when a system reminder says the action is repeating without progress,', - 'stop calling tools and reply exactly WEB_STATIONARITY_RECOVERED.', - ].join(' '), - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -try { - await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); - }); -} finally { - process.stdout.write(`${JSON.stringify({ ...proxy.counts(), stopped: true })}\n`); - await proxy.close(); -} diff --git a/packages/cli/tests/support/launch-apply-patch-gui.ts b/packages/cli/tests/support/launch-apply-patch-gui.ts deleted file mode 100644 index 34c72afd8..000000000 --- a/packages/cli/tests/support/launch-apply-patch-gui.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-apply-patch-gui.ts', - defaultPort: 4321, - configure: () => ({ config: {} }), - setup: async ({ workspace }) => { - await Promise.all([ - writeFile(path.join(workspace, 'first.ts'), 'export const first = false;\n'), - writeFile(path.join(workspace, 'second.ts'), 'export const second = false;\n'), - ]); - }, - afterCommit: async ({ canonicalWorkspace }) => ({ - first: path.join(canonicalWorkspace, 'first.ts'), - second: path.join(canonicalWorkspace, 'second.ts'), - added: path.join(canonicalWorkspace, 'added.ts'), - }), -}); diff --git a/packages/cli/tests/support/launch-auto-verify-gui.ts b/packages/cli/tests/support/launch-auto-verify-gui.ts deleted file mode 100644 index 3db5a9d9c..000000000 --- a/packages/cli/tests/support/launch-auto-verify-gui.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-auto-verify-gui.ts', - defaultPort: 4319, - configure: () => ({ config: { permissionMode: 'yolo' } }), - setup: async ({ root, workspace }) => { - const marker = path.join(root, 'auto-verify-marker'); - const afterTarget = path.join(workspace, 'after-trust.ts'); - await Promise.all([ - writeFile( - path.join(workspace, 'package.json'), - `${JSON.stringify( - { - name: 'blade-auto-verify-gui', - version: '1.0.0', - private: true, - scripts: { 'type-check': 'node type-check.cjs' }, - }, - null, - 2 - )}\n` - ), - writeFile(path.join(workspace, 'tsconfig.json'), '{}\n'), - writeFile( - path.join(workspace, 'type-check.cjs'), - [ - `require('node:fs').writeFileSync(${JSON.stringify( - marker - )}, process.env.BLADE_SESSION_ID || 'missing');`, - `process.stderr.write(${JSON.stringify( - `${afterTarget}(1,1): error TS9001: GUI_AUTO_VERIFY_SIGNAL\n` - )});`, - 'process.exitCode = 1;', - ].join('\n') - ), - ]); - }, - afterCommit: async ({ root, workspace }) => ({ - marker: path.join(root, 'auto-verify-marker'), - beforeTarget: path.join(workspace, 'before-trust.ts'), - afterTarget: path.join(workspace, 'after-trust.ts'), - }), -}); diff --git a/packages/cli/tests/support/launch-code-review-gui.ts b/packages/cli/tests/support/launch-code-review-gui.ts deleted file mode 100644 index 8e77f2e54..000000000 --- a/packages/cli/tests/support/launch-code-review-gui.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { execFile } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -const execFileAsync = promisify(execFile); -const authorized = (assignment: boolean) => - [ - 'export interface User {', - ' id: string;', - ' isAdmin: boolean;', - '}', - '', - 'export function isAuthorized(user: User, resourceOwnerId: string) {', - ' if (user.isAdmin) return true;', - ` return ${assignment ? '(user.id = resourceOwnerId)' : 'user.id === resourceOwnerId'};`, - '}', - '', - ].join('\n'); - -await launchRealApiGuiFixture({ - scriptName: 'launch-code-review-gui.ts', - defaultPort: 4341, - environment: { BLADE_TELEMETRY_DISABLED: '1' }, - configure: () => ({ - config: { - permissionMode: 'default', - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - }), - setup: async ({ workspace }) => { - await writeFile(path.join(workspace, 'authorization.ts'), authorized(false)); - }, - afterCommit: async ({ canonicalWorkspace }) => { - const target = path.join(canonicalWorkspace, 'authorization.ts'); - await writeFile(target, authorized(true)); - const beforeContent = await readFile(target, 'utf8'); - const beforeStatus = ( - await execFileAsync('git', ['status', '--porcelain=v1'], { - cwd: canonicalWorkspace, - encoding: 'utf8', - }) - ).stdout.trim(); - return { target, beforeContent, beforeStatus }; - }, -}); diff --git a/packages/cli/tests/support/launch-durable-interaction-gui.ts b/packages/cli/tests/support/launch-durable-interaction-gui.ts deleted file mode 100644 index 4999a8c5e..000000000 --- a/packages/cli/tests/support/launch-durable-interaction-gui.ts +++ /dev/null @@ -1,71 +0,0 @@ -import path from 'node:path'; -import { PersistentStore } from '../../src/context/storage/PersistentStore.js'; -import { SessionInteractionService } from '../../src/services/SessionInteractionService.js'; -import { SessionService } from '../../src/services/SessionService.js'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -const question = { - header: 'Channel', - question: 'Which release channel should Blade write?', - multiSelect: false, - options: [ - { label: 'Stable', description: 'Use the stable release channel' }, - { label: 'Canary', description: 'Use the early canary release channel' }, - ], -}; - -await launchRealApiGuiFixture({ - scriptName: 'launch-durable-interaction-gui.ts', - defaultPort: 4340, - readme: '# Durable interaction GUI\n', - configure: () => ({ - config: { - permissionMode: 'default', - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - }), - afterCommit: async ({ canonicalWorkspace, runtimeConfig }) => { - const sessionId = `interaction-gui-${Date.now()}`; - const target = path.join(canonicalWorkspace, 'gui-selected-channel.txt'); - await SessionService.createSessionMetadata(sessionId, canonicalWorkspace, { - title: 'Pending Channel Decision', - taskStatus: 'completed', - selectedModelId: runtimeConfig.currentModelId, - permissionMode: 'yolo', - }); - const store = new PersistentStore(canonicalWorkspace); - await store.saveMessage( - sessionId, - 'user', - [ - 'A Channel question will be recovered in the production Web UI.', - `After the recovered answer, call Write exactly once with file_path=${JSON.stringify( - target - )}.`, - 'Set content to the selected label followed by exactly one newline.', - 'That Write is the only allowed tool call. Never call AskUserQuestion again.', - 'Do not emit assistant text or end the turn before Write succeeds.', - 'After Write succeeds, reply exactly GUI_INTERACTION_RECOVERED.', - ].join(' ') - ); - const toolCallId = await store.saveToolUse(sessionId, 'AskUserQuestion', { - questions: [question], - }); - const request = await SessionInteractionService.request( - { - sessionId, - projectPath: canonicalWorkspace, - toolCallId, - toolName: 'AskUserQuestion', - }, - { - type: 'askUserQuestion', - message: 'Choose a release channel', - questions: [question], - } - ); - return { sessionId, requestId: request.requestId, target }; - }, -}); diff --git a/packages/cli/tests/support/launch-goal-verification-gui.ts b/packages/cli/tests/support/launch-goal-verification-gui.ts deleted file mode 100644 index f3b454371..000000000 --- a/packages/cli/tests/support/launch-goal-verification-gui.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-goal-verification-gui.ts', - defaultPort: 4343, - environment: { BLADE_TELEMETRY_DISABLED: '1' }, - configure: ({ model }) => ({ - config: { - permissionMode: 'yolo', - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - metadata: { - model: model.model, - prompt: - '/goal Create goal-gui.txt with the exact content GOAL_GUI_VERIFIED. ' + - 'Read it back, run npm test, then call UpdateGoal complete.', - }, - }), - setup: async ({ workspace }) => { - await Promise.all([ - writeFile( - path.join(workspace, 'package.json'), - `${JSON.stringify( - { - name: 'blade-goal-verification-gui', - private: true, - scripts: { test: 'node --test goal.test.cjs' }, - }, - null, - 2 - )}\n` - ), - writeFile( - path.join(workspace, 'goal.test.cjs'), - [ - "const assert = require('node:assert/strict');", - "const fs = require('node:fs');", - "const test = require('node:test');", - '', - "test('verified goal output', () => {", - " assert.equal(fs.readFileSync('goal-gui.txt', 'utf8').trim(), 'GOAL_GUI_VERIFIED');", - '});', - '', - ].join('\n') - ), - ]); - }, -}); diff --git a/packages/cli/tests/support/launch-independent-verification-gui.ts b/packages/cli/tests/support/launch-independent-verification-gui.ts deleted file mode 100644 index 51a8ee0a2..000000000 --- a/packages/cli/tests/support/launch-independent-verification-gui.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-independent-verification-gui.ts', - defaultPort: 4326, - environment: { BLADE_TELEMETRY_DISABLED: '1' }, - configure: () => ({ - config: { permissionMode: 'yolo' }, - metadata: { - prompt: [ - 'Read src/alpha.js, src/beta.js, and src/gamma.js.', - 'Then call ApplyPatch exactly once to change each exported value from', - 'false to true. Do not modify package.json or the test.', - 'After ApplyPatch succeeds, return exactly WEB_INDEPENDENT_VERIFY_OK.', - ].join(' '), - }, - }), - setup: async ({ workspace }) => { - await Promise.all([ - mkdir(path.join(workspace, 'src'), { recursive: true }), - mkdir(path.join(workspace, 'test'), { recursive: true }), - ]); - await Promise.all([ - writeFile( - path.join(workspace, 'package.json'), - `${JSON.stringify( - { - name: 'blade-independent-verification-gui', - private: true, - type: 'module', - scripts: { test: 'node --test' }, - }, - null, - 2 - )}\n` - ), - ...['alpha', 'beta', 'gamma'].map((name) => - writeFile( - path.join(workspace, `src/${name}.js`), - `export const ${name} = false;\n` - ) - ), - writeFile( - path.join(workspace, 'test/values.test.js'), - [ - "import assert from 'node:assert/strict';", - "import test from 'node:test';", - "import { alpha } from '../src/alpha.js';", - "import { beta } from '../src/beta.js';", - "import { gamma } from '../src/gamma.js';", - '', - "test('all values are enabled', () => {", - ' assert.equal(alpha, true);', - ' assert.equal(beta, true);', - ' assert.equal(gamma, true);', - '});', - '', - ].join('\n') - ), - ]); - }, -}); diff --git a/packages/cli/tests/support/launch-lsp-gui.ts b/packages/cli/tests/support/launch-lsp-gui.ts deleted file mode 100644 index 11fd9d45d..000000000 --- a/packages/cli/tests/support/launch-lsp-gui.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { mkdir, realpath, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-lsp-gui.ts', - defaultPort: 4320, - configure: () => ({ config: {} }), - setup: async ({ root, workspace }) => { - const trace = path.join(root, 'lsp-trace.jsonl'); - const pidFile = path.join(root, 'lsp.pid'); - await mkdir(path.join(workspace, '.blade'), { recursive: true }); - await Promise.all([ - writeFile( - path.join(workspace, '.blade', 'config.json'), - `${JSON.stringify( - { - lspServers: { - qualification: { - command: process.execPath, - args: [path.resolve(import.meta.dirname, 'fake-lsp-server.mjs')], - extensionToLanguage: { '.ts': 'typescript' }, - env: { LSP_TRACE_FILE: trace, LSP_PID_FILE: pidFile }, - diagnosticWaitTimeout: 2_000, - }, - }, - }, - null, - 2 - )}\n` - ), - writeFile(path.join(workspace, 'source.ts'), 'export const value = "ready";\n'), - ]); - }, - afterCommit: async ({ root, canonicalWorkspace }) => ({ - source: await realpath(path.join(canonicalWorkspace, 'source.ts')), - diagnosticTarget: path.join(canonicalWorkspace, 'diagnostic.ts'), - trace: path.join(root, 'lsp-trace.jsonl'), - pidFile: path.join(root, 'lsp.pid'), - }), -}); diff --git a/packages/cli/tests/support/launch-mcp-completion-gui.ts b/packages/cli/tests/support/launch-mcp-completion-gui.ts deleted file mode 100644 index 344423473..000000000 --- a/packages/cli/tests/support/launch-mcp-completion-gui.ts +++ /dev/null @@ -1,31 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-completion-gui.ts', - defaultPort: 4332, - readme: '# MCP completion GUI fixture\n', - configure: ({ root }) => { - const pidFile = path.join(root, 'pids'); - const traceFile = path.join(root, 'mcp-trace.jsonl'); - return { - config: { - mcpServers: { - completion: { - type: 'stdio', - command: process.execPath, - args: [path.resolve(import.meta.dirname, 'fake-mcp-completion-server.mjs')], - env: { - MCP_COMPLETION_NAMESPACE: 'PRIMARY', - MCP_COMPLETION_PID_FILE: pidFile, - MCP_COMPLETION_TRACE_FILE: traceFile, - }, - timeout: 15_000, - idleTimeout: 5_000, - }, - }, - }, - metadata: { pidFile, traceFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-content-gui.ts b/packages/cli/tests/support/launch-mcp-content-gui.ts deleted file mode 100644 index 882528e31..000000000 --- a/packages/cli/tests/support/launch-mcp-content-gui.ts +++ /dev/null @@ -1,30 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-content-gui.ts', - defaultPort: 4327, - readme: '# MCP resources and prompts GUI fixture\n', - configure: ({ root }) => { - const pidFile = path.join(root, 'mcp.pid'); - const traceFile = path.join(root, 'mcp-trace.jsonl'); - return { - config: { - mcpServers: { - content: { - type: 'stdio', - command: process.execPath, - args: [path.resolve(import.meta.dirname, 'fake-mcp-content-server.mjs')], - env: { - MCP_CONTENT_PID_FILE: pidFile, - MCP_CONTENT_TRACE_FILE: traceFile, - }, - timeout: 15_000, - idleTimeout: 4_000, - }, - }, - }, - metadata: { pidFile, traceFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-dynamic-catalog-gui.ts b/packages/cli/tests/support/launch-mcp-dynamic-catalog-gui.ts deleted file mode 100644 index 582b86b80..000000000 --- a/packages/cli/tests/support/launch-mcp-dynamic-catalog-gui.ts +++ /dev/null @@ -1,32 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-dynamic-catalog-gui.ts', - defaultPort: 4326, - readme: '# Dynamic MCP catalog GUI fixture\n', - configure: ({ root }) => { - const pidFile = path.join(root, 'mcp.pid'); - const traceFile = path.join(root, 'mcp-trace.jsonl'); - return { - config: { - mcpServers: { - dynamic: { - type: 'stdio', - command: process.execPath, - args: [ - path.resolve(import.meta.dirname, 'fake-mcp-dynamic-catalog-server.mjs'), - ], - env: { - MCP_DYNAMIC_PID_FILE: pidFile, - MCP_DYNAMIC_TRACE_FILE: traceFile, - }, - timeout: 15_000, - idleTimeout: 4_000, - }, - }, - }, - metadata: { pidFile, traceFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-elicitation-gui.ts b/packages/cli/tests/support/launch-mcp-elicitation-gui.ts deleted file mode 100644 index d4c8b1a60..000000000 --- a/packages/cli/tests/support/launch-mcp-elicitation-gui.ts +++ /dev/null @@ -1,26 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-elicitation-gui.ts', - defaultPort: 4322, - readme: '# MCP elicitation fixture\n', - configure: ({ root }) => { - const pidFile = path.join(root, 'mcp.pid'); - return { - config: { - mcpServers: { - elicitation: { - type: 'stdio', - command: process.execPath, - args: [ - path.resolve(import.meta.dirname, 'fake-mcp-elicitation-server.mjs'), - ], - env: { MCP_ELICITATION_PID_FILE: pidFile }, - }, - }, - }, - metadata: { pidFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-instructions-gui.ts b/packages/cli/tests/support/launch-mcp-instructions-gui.ts deleted file mode 100644 index 1920fcc71..000000000 --- a/packages/cli/tests/support/launch-mcp-instructions-gui.ts +++ /dev/null @@ -1,34 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-instructions-gui.ts', - defaultPort: 4331, - readme: '# MCP server instructions GUI fixture\n', - configure: ({ root }) => { - const generationFile = path.join(root, 'generation'); - const pidFile = path.join(root, 'pids'); - const traceFile = path.join(root, 'mcp-trace.jsonl'); - return { - config: { - mcpServers: { - instructions: { - type: 'stdio', - command: process.execPath, - args: [ - path.resolve(import.meta.dirname, 'fake-mcp-instructions-server.mjs'), - ], - env: { - MCP_INSTRUCTIONS_GENERATION_FILE: generationFile, - MCP_INSTRUCTIONS_PID_FILE: pidFile, - MCP_INSTRUCTIONS_TRACE_FILE: traceFile, - }, - timeout: 15_000, - idleTimeout: 4_000, - }, - }, - }, - metadata: { generationFile, pidFile, traceFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-logging-gui.ts b/packages/cli/tests/support/launch-mcp-logging-gui.ts deleted file mode 100644 index 9e3c1e42e..000000000 --- a/packages/cli/tests/support/launch-mcp-logging-gui.ts +++ /dev/null @@ -1,31 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-logging-gui.ts', - defaultPort: 4330, - readme: '# MCP logging GUI fixture\n', - configure: ({ root }) => { - const pidFile = path.join(root, 'mcp.pid'); - const traceFile = path.join(root, 'mcp-trace.jsonl'); - return { - config: { - mcpServers: { - logging: { - type: 'stdio', - command: process.execPath, - args: [path.resolve(import.meta.dirname, 'fake-mcp-logging-server.mjs')], - env: { - MCP_LOGGING_PID_FILE: pidFile, - MCP_LOGGING_TRACE_FILE: traceFile, - }, - logging: { level: 'warning' }, - timeout: 15_000, - idleTimeout: 4_000, - }, - }, - }, - metadata: { pidFile, traceFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-oauth-gui.ts b/packages/cli/tests/support/launch-mcp-oauth-gui.ts deleted file mode 100644 index 58d0fcdd9..000000000 --- a/packages/cli/tests/support/launch-mcp-oauth-gui.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { createServer } from 'node:http'; -import { execFile, spawn, type ChildProcess } from 'node:child_process'; -import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; - -const [root, rawPort = '4325', rawCallbackPort = '7787'] = process.argv.slice(2); -if ( - !root || - !path.isAbsolute(root) || - !/^\d+$/.test(rawPort) || - !/^\d+$/.test(rawCallbackPort) -) { - throw new Error( - 'Usage: bun launch-mcp-oauth-gui.ts [port] [callback-port]' - ); -} - -const port = Number(rawPort); -const callbackPort = Number(rawCallbackPort); -await assertPortAvailable(callbackPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const readyFile = path.join(root, 'oauth-ready.json'); -const traceFile = path.join(root, 'oauth-trace.jsonl'); -const fakeServer = path.resolve(import.meta.dirname, 'fake-mcp-oauth-server.mjs'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -const oauthFixture = spawn(process.execPath, [fakeServer], { - env: { - ...process.env, - MCP_OAUTH_READY_FILE: readyFile, - MCP_OAUTH_TRACE_FILE: traceFile, - }, - stdio: ['ignore', 'pipe', 'pipe'], -}); -oauthFixture.stdout?.pipe(process.stdout); -oauthFixture.stderr?.pipe(process.stderr); -const ready = await waitForReady(readyFile); - -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - mcpServers: { - oauth: { - type: 'http', - url: ready.mcpUrl, - oauth: { - enabled: true, - scopes: ['mcp:tools'], - callbackPort, - }, - timeout: 15_000, - idleTimeout: 5_000, - }, - }, - }, - null, - 2 - )}\n` -); -await writeFile(path.join(workspace, 'README.md'), '# MCP OAuth GUI fixture\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const blade = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -blade.stdout?.pipe(process.stdout); -blade.stderr?.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - traceFile, - oauthPid: ready.pid, - oauthOrigin: ready.origin, - port, - callbackPort, - })}\n` -); - -const stop = () => { - blade.kill('SIGTERM'); - oauthFixture.kill('SIGTERM'); -}; -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -process.once('exit', stop); -await new Promise((resolve, reject) => { - blade.once('error', reject); - blade.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); -}); -oauthFixture.kill('SIGTERM'); -await waitForExit(oauthFixture); - -interface OAuthReady { - pid: number; - origin: string; - mcpUrl: string; -} - -async function waitForReady(filePath: string): Promise { - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - try { - return JSON.parse(await readFile(filePath, 'utf8')) as OAuthReady; - } catch { - await new Promise((resolve) => setTimeout(resolve, 50)); - } - } - throw new Error('Timed out waiting for OAuth fixture'); -} - -async function assertPortAvailable(targetPort: number): Promise { - const probe = createServer(); - await new Promise((resolve, reject) => { - probe.once('error', reject); - probe.listen(targetPort, '127.0.0.1', resolve); - }); - await new Promise((resolve) => probe.close(() => resolve())); -} - -async function waitForExit(child: ChildProcess): Promise { - if (child.exitCode !== null || child.signalCode !== null) return; - await new Promise((resolve) => child.once('exit', () => resolve())); -} diff --git a/packages/cli/tests/support/launch-mcp-progress-gui.ts b/packages/cli/tests/support/launch-mcp-progress-gui.ts deleted file mode 100644 index a304000e6..000000000 --- a/packages/cli/tests/support/launch-mcp-progress-gui.ts +++ /dev/null @@ -1,29 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-progress-gui.ts', - defaultPort: 4324, - readme: '# MCP progress fixture\n', - configure: ({ root }) => { - const pidFile = path.join(root, 'mcp.pid'); - return { - config: { - mcpServers: { - lifecycle: { - type: 'stdio', - command: process.execPath, - args: [path.resolve(import.meta.dirname, 'fake-mcp-lifecycle-server.mjs')], - env: { - MCP_LIFECYCLE_PID_FILE: pidFile, - MCP_LIFECYCLE_PROGRESS_DELAY_MS: '3000', - }, - timeout: 15_000, - idleTimeout: 4_000, - }, - }, - }, - metadata: { pidFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-recovery-gui.ts b/packages/cli/tests/support/launch-mcp-recovery-gui.ts deleted file mode 100644 index 935792fbe..000000000 --- a/packages/cli/tests/support/launch-mcp-recovery-gui.ts +++ /dev/null @@ -1,39 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-recovery-gui.ts', - defaultPort: 4328, - readme: '# MCP connection recovery GUI fixture\n', - configure: ({ root }) => { - const generationFile = path.join(root, 'generation'); - const pidFile = path.join(root, 'pids'); - const traceFile = path.join(root, 'mcp-trace.jsonl'); - return { - config: { - mcpServers: { - recovery: { - type: 'stdio', - command: process.execPath, - args: [path.resolve(import.meta.dirname, 'fake-mcp-recovery-server.mjs')], - env: { - MCP_RECOVERY_GENERATION_FILE: generationFile, - MCP_RECOVERY_PID_FILE: pidFile, - MCP_RECOVERY_TRACE_FILE: traceFile, - }, - timeout: 15_000, - idleTimeout: 4_000, - recovery: { - maxAttempts: 3, - initialDelayMs: 20, - maxDelayMs: 50, - jitterRatio: 0, - terminalErrorThreshold: 1, - }, - }, - }, - }, - metadata: { generationFile, pidFile, traceFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-roots-sampling-gui.ts b/packages/cli/tests/support/launch-mcp-roots-sampling-gui.ts deleted file mode 100644 index 76d104283..000000000 --- a/packages/cli/tests/support/launch-mcp-roots-sampling-gui.ts +++ /dev/null @@ -1,33 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-roots-sampling-gui.ts', - defaultPort: 4323, - workspaceName: 'project with space', - readme: '# MCP roots and sampling fixture\n', - configure: ({ root }) => { - const pidFile = path.join(root, 'mcp.pid'); - return { - config: { - mcpServers: { - sampler: { - type: 'stdio', - command: process.execPath, - args: [ - path.resolve(import.meta.dirname, 'fake-mcp-roots-sampling-server.mjs'), - ], - env: { MCP_ROOTS_SAMPLING_PID_FILE: pidFile }, - sampling: { - enabled: true, - maxTokens: 64, - maxRequestsPerToolCall: 1, - maxInputBytes: 16_384, - }, - }, - }, - }, - metadata: { pidFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-tasks-gui.ts b/packages/cli/tests/support/launch-mcp-tasks-gui.ts deleted file mode 100644 index f0d7ed2fc..000000000 --- a/packages/cli/tests/support/launch-mcp-tasks-gui.ts +++ /dev/null @@ -1,47 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-tasks-gui.ts', - defaultPort: 4333, - readme: '# MCP Tasks GUI fixture\n', - configure: ({ root }) => { - const stateFile = path.join(root, 'task-state.json'); - const pidFile = path.join(root, 'pids'); - const traceFile = path.join(root, 'mcp-trace.jsonl'); - return { - config: { - mcpServers: { - tasks: { - type: 'stdio', - command: process.execPath, - args: [path.resolve(import.meta.dirname, 'fake-mcp-task-server.mjs')], - env: { - MCP_TASK_STATE_FILE: stateFile, - MCP_TASK_PID_FILE: pidFile, - MCP_TASK_TRACE_FILE: traceFile, - MCP_TASK_NAMESPACE: 'PRIMARY', - }, - tasks: { - enabled: true, - defaultTtlMs: 60_000, - pollIntervalMs: 100, - maxTasksPerSession: 8, - maxLifetimeMs: 60_000, - }, - recovery: { - maxAttempts: 5, - initialDelayMs: 20, - maxDelayMs: 50, - jitterRatio: 0, - terminalErrorThreshold: 1, - }, - timeout: 15_000, - idleTimeout: 5_000, - }, - }, - }, - metadata: { stateFile, pidFile, traceFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-mcp-tool-result-gui.ts b/packages/cli/tests/support/launch-mcp-tool-result-gui.ts deleted file mode 100644 index 42f20f7dc..000000000 --- a/packages/cli/tests/support/launch-mcp-tool-result-gui.ts +++ /dev/null @@ -1,32 +0,0 @@ -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-mcp-tool-result-gui.ts', - defaultPort: 4329, - readme: '# MCP tool result safety GUI fixture\n', - configure: ({ root }) => { - const pidFile = path.join(root, 'mcp.pid'); - const traceFile = path.join(root, 'mcp-trace.jsonl'); - return { - config: { - mcpServers: { - results: { - type: 'stdio', - command: process.execPath, - args: [ - path.resolve(import.meta.dirname, 'fake-mcp-tool-result-server.mjs'), - ], - env: { - MCP_TOOL_RESULT_PID_FILE: pidFile, - MCP_TOOL_RESULT_TRACE_FILE: traceFile, - }, - timeout: 15_000, - idleTimeout: 4_000, - }, - }, - }, - metadata: { pidFile, traceFile }, - }; - }, -}); diff --git a/packages/cli/tests/support/launch-permission-mode-gui.ts b/packages/cli/tests/support/launch-permission-mode-gui.ts deleted file mode 100644 index cd0c60c62..000000000 --- a/packages/cli/tests/support/launch-permission-mode-gui.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SessionService } from '../../src/services/SessionService.js'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-permission-mode-gui.ts', - defaultPort: 4339, - readme: '# Permission mode GUI\n', - configure: () => ({ - config: { - permissionMode: 'default', - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - }), - afterCommit: async ({ canonicalWorkspace, runtimeConfig }) => { - const sessionId = `permission-gui-${Date.now()}`; - await SessionService.createSessionMetadata(sessionId, canonicalWorkspace, { - title: 'Persistent YOLO Session', - taskStatus: 'completed', - selectedModelId: runtimeConfig.currentModelId, - permissionMode: 'yolo', - }); - return { sessionId }; - }, -}); diff --git a/packages/cli/tests/support/launch-plugin-marketplace-gui.ts b/packages/cli/tests/support/launch-plugin-marketplace-gui.ts deleted file mode 100644 index 9491049d9..000000000 --- a/packages/cli/tests/support/launch-plugin-marketplace-gui.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { PermissionMode } from '../../src/config/types.js'; -import { WorkspaceTrustService } from '../../src/security/WorkspaceTrustService.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; - -const execFileAsync = promisify(execFile); -const [root, rawPort = '4317'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-plugin-marketplace-gui.ts [port]'); -} - -const port = Number(rawPort); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const marketplace = path.join(workspace, 'marketplace'); -const storage = path.join(root, 'storage'); -const marker = `GUI_MARKETPLACE_${Date.now()}`; -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); -const config = buildRealApiRuntimeConfig(model); - -async function write(relativeRoot: string, relativePath: string, content: string) { - const filePath = path.join(relativeRoot, relativePath); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, 'utf8'); -} - -await write( - home, - '.blade/config.json', - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - permissionMode: PermissionMode.YOLO, - }, - null, - 2 - )}\n` -); -await write( - workspace, - 'package.json', - `${JSON.stringify({ - name: 'blade-plugin-marketplace-gui', - version: '1.0.0', - private: true, - })}\n` -); -await write( - workspace, - '.blade/settings.json', - `${JSON.stringify( - { - hooks: { - enabled: true, - PreToolUse: [ - { - name: 'gui-settings-review', - matcher: { tools: 'Read' }, - hooks: [ - { - type: 'command', - command: 'node -e "process.exit(0)"', - }, - ], - }, - ], - }, - }, - null, - 2 - )}\n` -); -await write( - marketplace, - '.blade-plugin/marketplace.json', - `${JSON.stringify( - { - name: 'gui-market', - description: 'Production GUI qualification Marketplace', - plugins: [ - { - name: 'gui-managed-plugin', - description: 'Managed GUI qualification plugin', - version: '1.0.0', - source: './plugins/gui-managed-plugin', - }, - { - name: 'gui-plugin-dependency', - description: 'Managed GUI plugin dependency', - version: '1.0.0', - source: './plugins/gui-plugin-dependency', - }, - ], - }, - null, - 2 - )}\n` -); -await write( - marketplace, - 'plugins/gui-managed-plugin/.blade-plugin/plugin.json', - `${JSON.stringify({ - name: 'gui-managed-plugin', - description: 'Managed GUI qualification plugin', - version: '1.0.0', - dependencies: { - 'gui-plugin-dependency': '^1.0.0', - }, - })}\n` -); -await write( - marketplace, - 'plugins/gui-plugin-dependency/.blade-plugin/plugin.json', - `${JSON.stringify({ - name: 'gui-plugin-dependency', - description: 'Managed GUI plugin dependency', - version: '1.0.0', - })}\n` -); -await write( - marketplace, - 'plugins/gui-managed-plugin/commands/reveal.md', - `--- -description: Return the production GUI Marketplace marker ---- -Return exactly ${marker}. -` -); - -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); -WorkspaceTrustService.resetInstance(); -await WorkspaceTrustService.getInstance().trust(canonicalWorkspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - marketplace: await realpath(marketplace), - storage, - marker, - port, - })}\n` -); - -const stop = () => { - child.kill('SIGTERM'); -}; -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); -}); diff --git a/packages/cli/tests/support/launch-reactive-compaction.ts b/packages/cli/tests/support/launch-reactive-compaction.ts deleted file mode 100644 index 6e11e0b79..000000000 --- a/packages/cli/tests/support/launch-reactive-compaction.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import type { AddressInfo } from 'node:net'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { PermissionMode } from '../../src/config/types.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; - -const [root, mode = 'web', rawPort = '4346'] = process.argv.slice(2); -if ( - !root || - !path.isAbsolute(root) || - !['web', 'tui'].includes(mode) || - !/^\d+$/.test(rawPort) -) { - throw new Error( - 'Usage: bun launch-reactive-compaction.ts [port]' - ); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; -process.env.BLADE_TELEMETRY_DISABLED = '1'; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' -); -if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); - -async function readRequestBody(request: import('node:http').IncomingMessage) { - const chunks: Buffer[] = []; - for await (const chunk of request) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks); -} - -function upstreamUrl(baseUrl: string, requestUrl: string | undefined): URL { - const target = new URL(baseUrl); - const incoming = new URL(requestUrl ?? '/', 'http://127.0.0.1'); - target.pathname = `${target.pathname.replace(/\/$/, '')}/${incoming.pathname.replace( - /^\//, - '' - )}`; - target.search = incoming.search; - return target; -} - -async function startProxy(baseUrl: string) { - let requestCount = 0; - let contextLimitCount = 0; - let delayedCompactions = 0; - let closePromise: Promise | undefined; - const server = createServer((request, response) => { - void (async () => { - const body = await readRequestBody(request); - const requestNumber = ++requestCount; - if (contextLimitCount === 0) { - contextLimitCount++; - response.writeHead(413, { 'content-type': 'application/json' }); - response.end( - JSON.stringify({ - error: { - type: 'invalid_request_error', - code: 'context_length_exceeded', - message: 'context_length_exceeded', - }, - }) - ); - process.stdout.write('[reactive-compaction-proxy] injected context limit\n'); - return; - } - if (requestNumber === 2) { - delayedCompactions++; - process.stdout.write('[reactive-compaction-proxy] compaction delayed\n'); - await new Promise((resolve) => setTimeout(resolve, 5_000)); - } - - const headers = new Headers(); - for (const [name, value] of Object.entries(request.headers)) { - if (!value || name === 'host' || name === 'content-length') continue; - headers.set(name, Array.isArray(value) ? value.join(', ') : value); - } - const upstream = await fetch(upstreamUrl(baseUrl, request.url), { - method: request.method ?? 'POST', - headers, - body: request.method === 'GET' || request.method === 'HEAD' ? undefined : body, - redirect: 'manual', - }); - const responseHeaders: Record = {}; - upstream.headers.forEach((value, name) => { - if ( - ![ - 'connection', - 'content-encoding', - 'content-length', - 'transfer-encoding', - ].includes(name) - ) { - responseHeaders[name] = value; - } - }); - response.writeHead(upstream.status, responseHeaders); - if (upstream.body) { - const reader = upstream.body.getReader(); - for (;;) { - const chunk = await reader.read(); - if (chunk.done) break; - response.write(Buffer.from(chunk.value)); - } - } - response.end(); - })().catch((error: unknown) => { - if (response.headersSent) { - response.destroy(error instanceof Error ? error : undefined); - return; - } - response.writeHead(502, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ error: { type: 'proxy_error' } })); - }); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - server.off('error', reject); - resolve(); - }); - }); - const address = server.address() as AddressInfo; - return { - baseURL: `http://127.0.0.1:${address.port}`, - counts: () => ({ requestCount, contextLimitCount, delayedCompactions }), - close: () => { - if (closePromise) return closePromise; - server.closeAllConnections(); - closePromise = new Promise((resolve, reject) => { - server.close((error) => { - if ( - !error || - (error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING' - ) { - resolve(); - return; - } - reject(error); - }); - }); - return closePromise; - }, - }; -} - -const proxy = await startProxy(model.baseURL ?? 'https://api.deepseek.com'); -const config = buildRealApiRuntimeConfig({ ...model, baseURL: proxy.baseURL }); -await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(workspace, { recursive: true }), -]); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - permissionMode: PermissionMode.YOLO, - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - null, - 2 - )}\n` -); -await writeFile( - path.join(workspace, 'README.md'), - '# Reactive compaction qualification\n' -); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const args = - mode === 'web' ? [bladeEntry, 'serve', '--port', String(port)] : [bladeEntry]; -const child = spawn(process.execPath, args, { - cwd: canonicalWorkspace, - env: process.env, - stdio: mode === 'web' ? ['ignore', 'pipe', 'pipe'] : 'inherit', -}); -if (mode === 'web') { - child.stdout?.pipe(process.stdout); - child.stderr?.pipe(process.stderr); -} -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - mode, - port, - proxy: proxy.baseURL, - model: model.model, - prompt: - 'Remember marker REACTIVE_SURFACE_MEMORY_7319. Reply with exactly REACTIVE_SURFACE_OK and nothing else.', - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -try { - await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade ${mode} exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade ${mode} exited from signal ${signal}`)); - return; - } - resolve(); - }); - }); -} finally { - process.stdout.write( - `${JSON.stringify({ mode, ...proxy.counts(), stopped: true })}\n` - ); - await proxy.close(); -} diff --git a/packages/cli/tests/support/launch-reasoning-effort-gui.ts b/packages/cli/tests/support/launch-reasoning-effort-gui.ts deleted file mode 100644 index 08614759f..000000000 --- a/packages/cli/tests/support/launch-reasoning-effort-gui.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { execFile, spawn } from 'node:child_process'; -import { appendFile, mkdir, realpath, writeFile } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, -} from '../integration/real-api/testConfig.js'; - -const [root, rawPort = '4336'] = process.argv.slice(2); -if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error('Usage: bun launch-reasoning-effort-gui.ts [port]'); -} - -const port = Number(rawPort); -const execFileAsync = promisify(execFile); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'project'); -const storage = path.join(root, 'storage'); -const requestEvidence = path.join(root, 'provider-requests.jsonl'); -const responseEvidence = path.join(root, 'provider-responses.jsonl'); -const projectedEnvironment = materializeRealApiEnvironment(process.env); -for (const [name, value] of Object.entries(projectedEnvironment)) { - if (value !== undefined) process.env[name] = value; -} -process.env.REAL_API_TEST = '1'; -process.env.HOME = home; -process.env.BLADE_STORAGE_ROOT = storage; - -const model = resolveForkQualificationModels(process.env).find( - (candidate) => candidate.id === 'gpt' -); -if (!model?.baseURL) { - throw new Error('GPT qualification channel is unavailable'); -} - -const upstream = new URL(model.baseURL); -const proxy = createServer(async (request, response) => { - try { - const chunks: Buffer[] = []; - for await (const chunk of request) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const body = Buffer.concat(chunks); - if (body.length > 0) { - await appendFile(requestEvidence, `${body.toString('utf8')}\n`, { - mode: 0o600, - }); - } - const incoming = new URL(request.url ?? '/', 'http://blade-proxy.invalid'); - const target = new URL(upstream.toString()); - const incomingPath = - target.pathname.endsWith('/v1') && incoming.pathname.startsWith('/v1/') - ? incoming.pathname.slice(3) - : incoming.pathname; - target.pathname = `${target.pathname.replace(/\/+$/, '')}/${incomingPath.replace( - /^\/+/, - '' - )}`; - target.search = incoming.search; - const headers = new Headers(); - for (const [name, value] of Object.entries(request.headers)) { - if ( - value === undefined || - ['host', 'connection', 'content-length'].includes(name.toLowerCase()) - ) { - continue; - } - headers.set(name, Array.isArray(value) ? value.join(', ') : value); - } - const upstreamResponse = await fetch(target, { - method: request.method, - headers, - body: body.length > 0 ? body : undefined, - }); - const responseBody = Buffer.from(await upstreamResponse.arrayBuffer()); - await appendFile( - responseEvidence, - `${JSON.stringify({ - status: upstreamResponse.status, - contentType: upstreamResponse.headers.get('content-type'), - bytes: responseBody.length, - errorBody: - upstreamResponse.status >= 400 - ? responseBody.toString('utf8').slice(0, 2_048) - : undefined, - })}\n`, - { mode: 0o600 } - ); - response.statusCode = upstreamResponse.status; - upstreamResponse.headers.forEach((value, name) => { - if ( - ![ - 'connection', - 'content-encoding', - 'content-length', - 'keep-alive', - 'transfer-encoding', - ].includes(name.toLowerCase()) - ) { - response.setHeader(name, value); - } - }); - response.end(responseBody); - } catch (error) { - const cause = - error instanceof Error && 'cause' in error && error.cause instanceof Error - ? error.cause - : undefined; - process.stderr.write( - `${JSON.stringify({ - proxyError: error instanceof Error ? error.message : String(error), - cause: cause?.message, - })}\n` - ); - response.statusCode = 502; - response.setHeader('content-type', 'application/json'); - response.end( - JSON.stringify({ - error: { message: 'Qualification proxy forwarding failed' }, - }) - ); - } -}); -await new Promise((resolve, reject) => { - proxy.once('error', reject); - proxy.listen(0, '127.0.0.1', () => { - proxy.off('error', reject); - resolve(); - }); -}); -const proxyAddress = proxy.address(); -if (!proxyAddress || typeof proxyAddress === 'string') { - throw new Error('Reasoning GUI proxy has no TCP address'); -} - -const config = buildRealApiRuntimeConfig(model); -const provider = config.models[0]?.provider; -if (!provider || !config.modelProviders[provider]) { - throw new Error('GPT qualification provider projection is unavailable'); -} -config.modelProviders[provider] = { - ...config.modelProviders[provider], - baseUrl: `http://127.0.0.1:${proxyAddress.port}/v1`, -}; - -await mkdir(path.join(home, '.blade'), { recursive: true }); -await mkdir(workspace, { recursive: true }); -await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: config.currentModelId, - models: config.models, - modelProviders: config.modelProviders, - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - null, - 2 - )}\n` -); -await Promise.all([ - mkdir(path.join(workspace, '.blade', 'output-styles'), { recursive: true }), - mkdir(path.join(workspace, '.claude', 'rules'), { recursive: true }), - mkdir(path.join(workspace, 'src'), { recursive: true }), - mkdir(path.join(workspace, 'test'), { recursive: true }), - mkdir(path.join(workspace, '.blade', 'plugins', 'gui-style', '.blade-plugin'), { - recursive: true, - }), - mkdir(path.join(workspace, '.blade', 'plugins', 'gui-style', 'output-styles'), { - recursive: true, - }), -]); -await Promise.all([ - writeFile( - path.join(workspace, 'BLADE.md'), - `# GUI project instructions - -Read requested source files and obey any path-specific rule loaded for them. -Do not infer a path-specific marker before reading the target file. -` - ), - writeFile( - path.join(workspace, '.claude', 'rules', 'source.md'), - `--- -paths: src/**/*.ts ---- -After reading a matching source file for GUI qualification, reply exactly GUI_CONTEXTUAL_RULE_OK. -GUI_CONTEXTUAL_SOURCE_RULE_MARKER -` - ), - writeFile( - path.join(workspace, '.claude', 'rules', 'test-only.md'), - `--- -paths: test/**/*.ts ---- -GUI_NON_MATCHING_TEST_RULE_MARKER -` - ), - writeFile( - path.join(workspace, 'src', 'target.ts'), - `export const target = 'read-me'; -` - ), - writeFile( - path.join(workspace, '.blade', 'output-styles', 'gui-project.md'), - `--- -name: GUI Project -description: Production GUI project style ---- -Keep all safety, permission, and completion rules unchanged. -When the user requests a GUI qualification marker, return only that marker. -GUI_PROJECT_STYLE_MARKER -` - ), - writeFile( - path.join( - workspace, - '.blade', - 'plugins', - 'gui-style', - '.blade-plugin', - 'plugin.json' - ), - `${JSON.stringify({ - name: 'gui-style', - version: '1.0.0', - description: 'Production GUI output style qualification', - })}\n` - ), - writeFile( - path.join( - workspace, - '.blade', - 'plugins', - 'gui-style', - 'output-styles', - 'gui-plugin.md' - ), - `--- -name: GUI Plugin -description: Production GUI plugin style ---- -Keep all safety, permission, and completion rules unchanged. -When the user requests a GUI qualification marker, return only that marker. -GUI_PLUGIN_STYLE_MARKER -` - ), -]); -await writeFile(path.join(workspace, 'README.md'), '# Reasoning effort GUI\n'); -await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); -await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, -}); -await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, -}); -await execFileAsync('git', ['add', '.'], { cwd: workspace }); -await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); -const canonicalWorkspace = await realpath(workspace); - -const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); -const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stdout.pipe(process.stdout); -child.stderr.pipe(process.stderr); -process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - requestEvidence, - responseEvidence, - port, - })}\n` -); - -const stop = () => child.kill('SIGTERM'); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); -try { - await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - return; - } - if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - return; - } - resolve(); - }); - }); -} finally { - await new Promise((resolve) => proxy.close(() => resolve())); -} diff --git a/packages/cli/tests/support/launch-session-archive-gui.ts b/packages/cli/tests/support/launch-session-archive-gui.ts deleted file mode 100644 index e25404bc4..000000000 --- a/packages/cli/tests/support/launch-session-archive-gui.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-session-archive-gui.ts', - defaultPort: 4334, - readme: '# Session Archive GUI fixture\n', - configure: () => ({ - config: { - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - }), -}); diff --git a/packages/cli/tests/support/launch-session-export-gui.ts b/packages/cli/tests/support/launch-session-export-gui.ts deleted file mode 100644 index 02eb02535..000000000 --- a/packages/cli/tests/support/launch-session-export-gui.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -const marker = `EXPORT_GUI_VISIBLE_${Date.now()}`; - -await launchRealApiGuiFixture({ - scriptName: 'launch-session-export-gui.ts', - defaultPort: 4335, - readme: '# Session Export GUI fixture\n', - configure: () => ({ - config: { - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - metadata: { marker }, - }), - setup: async ({ workspace }) => { - await writeFile( - path.join(workspace, 'evidence.txt'), - `${marker}\nsk-EXPORT_GUI_SECRET_1234567890\n/Users/gui-owner/private/file.txt\n` - ); - }, -}); diff --git a/packages/cli/tests/support/launch-structured-output-gui.ts b/packages/cli/tests/support/launch-structured-output-gui.ts deleted file mode 100644 index 9f913bc91..000000000 --- a/packages/cli/tests/support/launch-structured-output-gui.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-structured-output-gui.ts', - defaultPort: 4342, - readme: '# Structured output GUI qualification\n', - environment: { BLADE_TELEMETRY_DISABLED: '1' }, - configure: ({ model }) => ({ - config: { - permissionMode: 'autoEdit', - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - metadata: { model: model.model }, - }), -}); diff --git a/packages/cli/tests/support/launch-turn-recovery-gui.ts b/packages/cli/tests/support/launch-turn-recovery-gui.ts deleted file mode 100644 index e6148d93c..000000000 --- a/packages/cli/tests/support/launch-turn-recovery-gui.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { PermissionMode } from '../../src/config/types.js'; -import { SessionService } from '../../src/services/SessionService.js'; -import { ensureStoreInitialized, getState } from '../../src/store/vanilla.js'; -import { seedRootTurnAutoResumeFixture } from '../integration/real-api/rootTurnAutoResumeFixture.js'; -import { launchRealApiGuiFixture } from './launchRealApiGuiFixture.js'; - -await launchRealApiGuiFixture({ - scriptName: 'launch-turn-recovery-gui.ts', - defaultPort: 4341, - readme: '# Turn recovery GUI\n', - configure: () => ({ - config: { - permissionMode: 'yolo', - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }, - }), - setup: async ({ runtimeConfig }) => { - await ensureStoreInitialized(); - getState().config.actions.setConfig({ - ...runtimeConfig, - permissionMode: PermissionMode.YOLO, - hooks: { enabled: false }, - disableAllHooks: true, - mcpServers: {}, - }); - }, - afterCommit: async ({ canonicalWorkspace }) => { - const sessionId = `turn-recovery-gui-${Date.now()}`; - await SessionService.createSessionMetadata(sessionId, canonicalWorkspace, { - title: 'Recovery Review Required', - taskStatus: 'completed', - permissionMode: PermissionMode.YOLO, - }); - const fixture = await seedRootTurnAutoResumeFixture({ - workspace: canonicalWorkspace, - sessionId, - marker: 'GUI_RECOVERY_MARKER', - }); - return { - sessionId, - markerPath: fixture.markerPath, - expectedResponse: fixture.expectedResponse, - }; - }, -}); diff --git a/packages/cli/tests/support/launchRealApiGuiFixture.ts b/packages/cli/tests/support/launchRealApiGuiFixture.ts deleted file mode 100644 index ffcdbc2b8..000000000 --- a/packages/cli/tests/support/launchRealApiGuiFixture.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { execFile, spawn } from 'node:child_process'; -import { mkdir, realpath, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { materializeRealApiEnvironment } from '../../scripts/real-api-credentials.js'; -import { - buildRealApiRuntimeConfig, - resolveForkQualificationModels, - type TestModelConfig, -} from '../integration/real-api/testConfig.js'; - -const execFileAsync = promisify(execFile); - -type RuntimeConfig = ReturnType; - -export interface RealApiGuiFixtureContext { - root: string; - home: string; - workspace: string; - storage: string; - port: number; - model: TestModelConfig; - runtimeConfig: RuntimeConfig; -} - -interface CommittedRealApiGuiFixtureContext extends RealApiGuiFixtureContext { - canonicalWorkspace: string; -} - -interface RealApiGuiFixtureOptions { - scriptName: string; - defaultPort: number; - readme?: string; - workspaceName?: string; - environment?: Readonly>; - configure(context: RealApiGuiFixtureContext): { - config: Record; - metadata?: Record; - }; - setup?(context: RealApiGuiFixtureContext): Promise; - afterCommit?( - context: CommittedRealApiGuiFixtureContext - ): Promise | undefined>; -} - -export async function launchRealApiGuiFixture( - options: RealApiGuiFixtureOptions -): Promise { - const [root, rawPort = String(options.defaultPort)] = process.argv.slice(2); - if (!root || !path.isAbsolute(root) || !/^\d+$/.test(rawPort)) { - throw new Error(`Usage: bun ${options.scriptName} [port]`); - } - - const home = path.join(root, 'home'); - const workspace = path.join(root, options.workspaceName ?? 'project'); - const storage = path.join(root, 'storage'); - const port = Number(rawPort); - for (const [name, value] of Object.entries( - materializeRealApiEnvironment(process.env) - )) { - if (value !== undefined) process.env[name] = value; - } - process.env.REAL_API_TEST = '1'; - process.env.HOME = home; - process.env.BLADE_STORAGE_ROOT = storage; - Object.assign(process.env, options.environment); - - const model = resolveForkQualificationModels(process.env).find( - (candidate) => - candidate.id === 'deepseek' && candidate.model === 'deepseek-v4-flash' - ); - if (!model) throw new Error('DeepSeek Flash qualification model is unavailable'); - const runtimeConfig = buildRealApiRuntimeConfig(model); - const context = { root, home, workspace, storage, port, model, runtimeConfig }; - const fixture = options.configure(context); - - await Promise.all([ - mkdir(path.join(home, '.blade'), { recursive: true }), - mkdir(workspace, { recursive: true }), - ]); - await writeFile( - path.join(home, '.blade', 'config.json'), - `${JSON.stringify( - { - currentModelId: runtimeConfig.currentModelId, - models: runtimeConfig.models, - modelProviders: runtimeConfig.modelProviders, - ...fixture.config, - }, - null, - 2 - )}\n` - ); - if (options.readme !== undefined) { - await writeFile(path.join(workspace, 'README.md'), options.readme); - } - await options.setup?.(context); - await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: workspace }); - await execFileAsync('git', ['config', 'user.email', 'blade@example.test'], { - cwd: workspace, - }); - await execFileAsync('git', ['config', 'user.name', 'Blade Test'], { - cwd: workspace, - }); - await execFileAsync('git', ['add', '.'], { cwd: workspace }); - await execFileAsync('git', ['commit', '-qm', 'fixture'], { cwd: workspace }); - - const canonicalWorkspace = await realpath(workspace); - const committedMetadata = await options.afterCommit?.({ - ...context, - canonicalWorkspace, - }); - const bladeEntry = path.resolve(import.meta.dirname, '../../dist/blade.js'); - const child = spawn(process.execPath, [bladeEntry, 'serve', '--port', String(port)], { - cwd: canonicalWorkspace, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - child.stdout.pipe(process.stdout); - child.stderr.pipe(process.stderr); - process.stdout.write( - `${JSON.stringify({ - home, - workspace: canonicalWorkspace, - storage, - port, - ...fixture.metadata, - ...committedMetadata, - })}\n` - ); - - const stop = () => child.kill('SIGTERM'); - process.once('SIGINT', stop); - process.once('SIGTERM', stop); - await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code && code !== 0) { - reject(new Error(`Blade server exited with code ${code}`)); - } else if (signal && signal !== 'SIGTERM') { - reject(new Error(`Blade server exited from signal ${signal}`)); - } else { - resolve(); - } - }); - }); -} From a2bcef5aad59aead034f100100b5916156e1d033 Mon Sep 17 00:00:00 2001 From: echoVic <137844255@qq.com> Date: Wed, 16 Sep 2026 15:37:10 +0800 Subject: [PATCH 23/59] refactor(test): remove orphan qualification fixtures --- ...reate-workspace-agent-resource-fixture.mjs | 88 ---- ...reate-workspace-model-resource-fixture.mjs | 78 ---- .../taskOwnerIdentitySurfaceQualification.ts | 423 ------------------ 3 files changed, 589 deletions(-) delete mode 100644 packages/cli/tests/support/create-workspace-agent-resource-fixture.mjs delete mode 100644 packages/cli/tests/support/create-workspace-model-resource-fixture.mjs delete mode 100644 packages/cli/tests/support/taskOwnerIdentitySurfaceQualification.ts diff --git a/packages/cli/tests/support/create-workspace-agent-resource-fixture.mjs b/packages/cli/tests/support/create-workspace-agent-resource-fixture.mjs deleted file mode 100644 index 7a2d22566..000000000 --- a/packages/cli/tests/support/create-workspace-agent-resource-fixture.mjs +++ /dev/null @@ -1,88 +0,0 @@ -import { execFile } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); - -const [workspace, label, marker] = process.argv.slice(2); -if (!workspace || !/^[a-z]$/.test(label ?? '') || !marker) { - throw new Error( - 'Usage: node create-workspace-agent-resource-fixture.mjs