diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a94c167 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +tests/snapshots/**/*.txt text eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..75c945f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,80 @@ +# Cross-platform CI for pi-agenticoding +# +# Runs the full unit suite on Linux, macOS, and Windows +# on the minimum Node.js version required by pi coding agent. Snapshot +# tests verify TUI render output against golden files. + +name: test + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths-ignore: ['*.md', '**/docs/**'] + pull_request: + branches: [main] + paths-ignore: ['*.md', '**/docs/**'] + +jobs: + # ── Cross-platform test matrix ────────────────────────────────────── + # Node 22 (minimum) is tested only on Linux — the primary platform and the only one + # guaranteed to have the oldest toolchain. macOS and Windows test Node 24 (latest) + # to catch regressions in the newest runtime. This asymmetry is intentional: it + # balances CI cost with meaningful coverage while ensuring the minimum version works + # correctly on the platform most likely to encounter toolchain edge cases. + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false # report every combination, don't cancel + matrix: + include: + - os: ubuntu-latest + node-version: "22" # minimum version on primary platform + - os: ubuntu-latest + node-version: "24" # latest on primary platform + - os: macos-latest + node-version: "24" # latest on macOS + - os: windows-latest + node-version: "24" # latest on Windows + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + + - run: npm ci + + # Uniform pre-flight checks — type errors and security issues on every platform + - name: Type check + run: npx tsc --noEmit + + - name: Security audit + run: npm audit --audit-level=moderate + + # Unit suite (unit tests + snapshot tests + property-based tests) + - name: Unit tests + run: npm test + + # E2E tests — process-isolated child-process harness (stdin/stdout, no PTY). + # Verified cross-platform: runs on Linux, macOS, and Windows. + # See https://github.com/agenticoding/pi-agenticoding/issues/12 + - name: E2E tests + run: npm run test:e2e + + # Upload test results for debugging — artifacts available for 30 days. + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }}-node-${{ matrix.node-version }} + path: | + tests/snapshots/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 9de7b0d..ebc8ac7 100644 --- a/.gitignore +++ b/.gitignore @@ -142,8 +142,7 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ -# Lockfiles (library package — consumers manage their own) -package-lock.json +# package-lock.json committed for reproducible CI installs (excluded from publish) # Agenticoding local config (credentials, API keys) .chunkhound.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 694b0de..99331fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,14 +7,15 @@ Welcome! This project welcomes focused, well-validated contributions. Use coding - **Use code research first** — understand the surrounding module responsibilities before editing. - **Make minimal changes** — prefer targeted edits that reuse existing mechanisms. - **Match existing patterns** — keep naming, lifecycle hooks, tool contracts, and TUI behavior consistent with the current code. -- **Preserve context-management semantics** — changes to `spawn`, `ledger`, or `handoff` should keep the agent workflow predictable across session resets and compaction. +- **Preserve context-management semantics** — changes to `spawn`, `notebook`, or `handoff` should keep the agent workflow predictable across session resets and compaction. +- **Use static imports only for `spawn/renderer.ts`** — it registers the frame scheduler into the singleton container at module evaluation time. Switching to `await import()` will silently break test isolation because the test harness cannot overwrite the singleton before registration. - **AI-agent generated contributions are welcome** — include enough human intent and validation context in the PR for reviewers to trust the result. ## Suggested Workflow 1. **Research the area** - - Identify the relevant primitive: spawn, ledger, handoff, watchdog, or extension wiring. - - Read nearby tests in `agenticoding.test.ts` before changing behavior. + - Identify the relevant primitive: spawn, notebook, handoff, watchdog, or extension wiring. + - Read the relevant suite in `tests/unit/` before changing behavior. 2. **Plan the smallest safe change** - Reuse existing state and lifecycle hooks when possible. @@ -38,6 +39,29 @@ Before submitting, check that your change: - Handles reset, cancellation, and stale-session cases where relevant. - Keeps docs aligned with the package version and installed behavior. +## Tests + +- `npm test` — runs the unit suite under `tests/unit/` via the in-repo Node test runner. +- `npm run test:snapshots:check` — runs only the render-snapshot tests; fails on any drift in `tests/snapshots/`. +- `npm run test:snapshots:update` — rewrites the golden files in `tests/snapshots/` after an intentional render change. Review the diff carefully: snapshot updates are the only signal that catches unintended UI regressions. +- `npm run test:e2e` — runs the process-isolated end-to-end suite under `tests/e2e/`. + +## CI + +Pull requests are automatically tested via GitHub Actions. A cross-platform matrix runs on every push and PR: + +| OS | Node | Runs | +|---|---|---| +| Ubuntu | 22 (minimum) | Type check, security audit, unit tests, E2E tests | +| Ubuntu | 24 | Type check, security audit, unit tests, E2E tests | +| macOS | 24 | Unit tests, E2E tests | +| Windows | 24 | Unit tests, E2E tests | + +Node 22 (minimum) is tested only on Linux — the primary platform and the only one guaranteed to have the oldest toolchain. macOS and Windows test Node 24 (latest) to catch regressions in the newest runtime while balancing CI cost. + +Snapshot golden files in `tests/snapshots/` are stored with LF line endings (enforced by `.gitattributes`). The `normalizeEOL` helper in the snapshot test file normalizes `\r\n` to `\n` on read, so Windows developers get correct comparisons even if their working tree has CRLF. If you update snapshots, the CI matrix validates them on all platforms. +The E2E suite runs on all platforms including Windows (verified in issue #12). + ## Community Use GitHub Issues for bug reports and feature requests. Keep discussions concrete: describe the agent workflow you expected, what happened instead, and any reproduction steps. diff --git a/agenticoding.test.ts b/agenticoding.test.ts deleted file mode 100644 index 5468314..0000000 --- a/agenticoding.test.ts +++ /dev/null @@ -1,3840 +0,0 @@ -import test, { after } from "node:test"; -import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { AuthStorage, ModelRegistry, type Theme } from "@earendil-works/pi-coding-agent"; -import { Text } from "@earendil-works/pi-tui"; -import { registerHandoffCommand } from "./handoff/command.js"; -import { registerHandoffTool } from "./handoff/tool.js"; -import { registerHandoffCompaction } from "./handoff/compact.js"; -import { buildNudge, registerWatchdog } from "./watchdog.js"; -import { createState, resetState } from "./state.js"; -import { - buildChildToolNames, - createChildTools, - executeSpawn, - registerSpawnTool, -} from "./spawn/index.js"; -import { renderSpawnResult, flushSpawnFrameScheduler, resetSpawnFrameScheduler } from "./spawn/renderer.js"; -import { registerNotebookRehydration } from "./notebook/rehydration.js"; -import { clearActiveNotebookTopic, setActiveNotebookTopic } from "./notebook/topic.js"; -import { registerNotebookTopicTool } from "./notebook/topic-tool.js"; -import { saveNotebookPage, resetNotebookWriteLock } from "./notebook/store.js"; -import { createNotebookToolDefinitions } from "./notebook/tools.js"; -import registerAgenticoding from "./index.js"; -import { CONTEXT_PRIMER } from "./system-prompt.js"; -import { STATUS_KEY_HANDOFF, STATUS_KEY_TOPIC, WIDGET_KEY_WARNING, updateIndicators } from "./tui.js"; - -// Safety net: reset module-level mutable state after all tests. -// Individual tests should also call reset*() at the start for explicit isolation. -after(() => { - resetNotebookWriteLock(); - resetSpawnFrameScheduler(); -}); - -type Handler = (args: any, ctx: any) => any; - -const theme = { - fg: (_name: string, text: string) => text, - bold: (text: string) => text, -} as unknown as Theme; - -const ansiTheme = { - fg: (_name: string, text: string) => `\u001b[38;5;245m${text}\u001b[39m`, - bg: (_name: string, text: string) => `\u001b[48;5;236m${text}\u001b[49m`, - bold: (text: string) => text, -} as unknown as Theme; - -function createRenderContext(overrides: Record = {}): Record { - return { - expanded: false, - showImages: true, - toolCallId: "tool-call-1", - lastComponent: undefined, - invalidate: () => {}, - ...overrides, - }; -} - -function createSession(messages: any[]) { - return { - messages, - subscribe: () => () => {}, - getToolDefinition: () => undefined, - sessionManager: { getCwd: () => process.cwd() }, - abort: async () => {}, - } as unknown as import("@earendil-works/pi-coding-agent").AgentSession; -} - -function stripAnsi(text: string): string { - return text.replace(/\u001b\[[0-9;]*m/g, "").replace(/\u001b\][^\u0007]*\u0007/g, ""); -} - -function getRenderedLine(lines: string[], match: (plain: string) => boolean): string { - const line = lines.find(candidate => match(stripAnsi(candidate))); - assert.ok(line); - return line; -} - -function getLineContaining(lines: string[], text: string): string { - const line = lines.find(candidate => candidate.includes(text)); - assert.ok(line); - return line; -} - -function assertShellBackgroundPreserved(line: string): void { - assert.equal(line.includes("\u001b[0m"), false); - assert.match(line, /\u001b\[48;/); -} - -function createDeferred() { - let resolve!: () => void; - const promise = new Promise((r) => { resolve = r; }); - return { promise, resolve }; -} - -function createChildSpawnTool(state: any): any { - const pi = new MockPi(); - registerSpawnTool(pi as any, state); - return pi.tools.get("spawn"); -} - -class MockPi { - commands = new Map(); - tools = new Map(); - handlers = new Map(); - activeTools: string[] = []; - allToolNames: string[] | undefined; - toolSources = new Map(); - sentUserMessages: Array<{ content: string; options: any }> = []; - appendedEntries: Array<{ customType: string; data: any }> = []; - - registerCommand(name: string, definition: { description?: string; handler: Handler }) { - this.commands.set(name, definition); - } - - registerTool(definition: any) { - this.tools.set(definition.name, definition); - } - - on(event: string, handler: Handler) { - const handlers = this.handlers.get(event) ?? []; - handlers.push(handler); - this.handlers.set(event, handlers); - } - - getActiveTools() { - return [...this.activeTools]; - } - - setActiveTools(tools: string[]) { - this.activeTools = [...tools]; - for (const tool of tools) { - if (!this.toolSources.has(tool)) { - this.toolSources.set(tool, "builtin"); - } - } - } - - setToolSource(name: string, source: string) { - this.toolSources.set(name, source); - } - - setAllTools(tools: string[]) { - this.allToolNames = [...tools]; - for (const tool of tools) { - if (!this.toolSources.has(tool)) { - this.toolSources.set(tool, "builtin"); - } - } - } - - getAllTools() { - return (this.allToolNames ?? this.activeTools).map((name) => ({ - name, - description: "", - parameters: {}, - sourceInfo: { - path: `<${this.toolSources.get(name) ?? "builtin"}:${name}>`, - source: this.toolSources.get(name) ?? "builtin", - scope: "temporary", - origin: "top-level", - }, - })); - } - - getThinkingLevel() { - return "medium"; - } - - sendUserMessage(content: string, options?: any) { - this.sentUserMessages.push({ content, options }); - } - - appendEntry(customType: string, data: any) { - this.appendedEntries.push({ customType, data }); - } -} - -const EMPTY_USAGE = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -}; - -function createTestAssistantMessage(model: any, content: any[], stopReason = "stop") { - return { - role: "assistant", - content, - api: model.api, - provider: model.provider, - model: model.id, - usage: EMPTY_USAGE, - stopReason, - timestamp: Date.now(), - }; -} - -function createTestAssistantStream(message: any): any { - return { - async *[Symbol.asyncIterator]() { - yield { type: "done", reason: message.stopReason, message }; - }, - result: async () => message, - }; -} - -function messageText(message: any): string { - return (message.content ?? []) - .map((block: any) => block.type === "text" ? block.text : JSON.stringify(block)) - .join("\n"); -} - -// ── TUI indicator tests ─────────────────────────────────────────────── - -function makeTUICtx( - overrides: Partial<{ - percent: number | null; - hasUI: boolean; - record: { statuses: Map; widgets: Map }; - }> = {}, -): any { - const record = overrides.record ?? { statuses: new Map(), widgets: new Map() }; - const hasUI = overrides.hasUI ?? true; - const percent = overrides.percent !== undefined ? overrides.percent : null; - return { - hasUI, - ui: { - theme: { - fg: (name: string, text: string) => `[${name}:${text}]`, - }, - setStatus: (key: string, status: string | undefined) => { record.statuses.set(key, status); }, - setWidget: (key: string, content: string[] | undefined) => { record.widgets.set(key, content); }, - }, - getContextUsage: () => (percent !== null ? { percent } : null), - }; -} - -test("updateIndicators sets context usage status with correct color tone", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 42, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("[accent:42%]"), "42% should use accent tone"); - assert.equal(record.widgets.get("agenticoding-warning"), undefined, "42% is below 70 — no warning widget"); -}); - -test("updateIndicators uses error tone at 70%+ context", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 85, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("[error:85%]"), "85% should use error tone"); - const w = record.widgets.get("agenticoding-warning"); - assert.ok(w?.[0]?.includes("85%"), "warning widget shown at 85%"); -}); - -test("updateIndicators uses warning tone at 50-69% context", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 55, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("[warning:55%]"), "55% should use warning tone"); -}); - -test("updateIndicators uses accent tone at 30-49% context", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 30, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("[accent:30%]"), "30% should use accent tone"); -}); - -test("updateIndicators handles null context usage", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: null, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("--%"), "null usage shows --%"); -}); - -test("updateIndicators no-ops when ctx.hasUI is false", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ hasUI: false, record }); - - updateIndicators(ctx, state); - assert.equal(record.statuses.size, 0, "no-op should not call any setStatus"); - assert.equal(record.widgets.size, 0, "no-op should not call any setWidget"); -}); - -test("updateIndicators shows notebook page count in status", () => { - const state = createState(); - state.notebookPages.set("entry-1", "first entry"); - state.notebookPages.set("entry-2", "second entry"); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: null, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-notebook"); - assert.ok(s?.includes("2"), "notebook page count should be 2"); -}); - -test("updateIndicators shows active notebook topic when set", () => { - const state = createState(); - state.activeNotebookTopic = "oauth"; - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 30, record }); - - updateIndicators(ctx, state); - assert.equal(record.statuses.get(STATUS_KEY_TOPIC), "🧭 oauth"); -}); - -test("updateIndicators hides widget below 70% context", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - // Pre-set a widget to verify it gets cleared - record.widgets.set("agenticoding-warning", ["existing"]); - const ctx = makeTUICtx({ percent: 30, record }); - - updateIndicators(ctx, state); - assert.equal(record.widgets.get("agenticoding-warning"), undefined, "warning widget should be cleared below 70%"); -}); - -// ── Handoff tests ───────────────────────────────────────────────────── - -test("/handoff sends the direction back through the LLM without opening the editor", async () => { - const pi = new MockPi(); - const state = createState(); - registerHandoffCommand(pi as any, state); - - await pi.commands.get("handoff")!.handler("implement auth", { - hasUI: true, - isIdle: () => true, - ui: { notify: (_message: string) => {} }, - }); - - assert.deepEqual(state.pendingRequestedHandoff, { - direction: "implement auth", - enforcementAttempts: 0, - toolCalled: false, - }); - assert.deepEqual(pi.sentUserMessages, [ - { - content: - "Handoff direction: implement auth\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.", - options: undefined, - }, - ]); -}); - -test("/handoff requires a direction", async () => { - const pi = new MockPi(); - const state = createState(); - registerHandoffCommand(pi as any, state); - - const notifications: string[] = []; - await pi.commands.get("handoff")!.handler(" ", { - hasUI: true, - isIdle: () => true, - ui: { notify: (message: string) => notifications.push(message) }, - }); - - assert.deepEqual(notifications, ["Usage: /handoff "]); - assert.deepEqual(pi.sentUserMessages, []); -}); - -test("handoff tool triggers compaction and resumes with the compacted task", async () => { - const pi = new MockPi(); - const state = createState(); - state.notebookPages.set("auth-refresh", "sensitive notebook body"); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; - registerHandoffTool(pi as any, state); - - let compactOptions: any; - const result = await pi.tools.get("handoff").execute( - "1", - { task: "Goal: continue auth-refresh" }, - undefined, - undefined, - { - compact: (options: any) => { - compactOptions = options; - }, - }, - ); - - assert.equal(state.pendingHandoff?.source, "tool"); - assert.match(state.pendingHandoff?.task ?? "", /## Handoff — Continue Previous Work/); - assert.match(state.pendingHandoff?.task ?? "", /Notebook pages hold durable grounding knowledge/); - assert.match(state.pendingHandoff?.task ?? "", /distilled next task and immediate situational context/); - assert.match(state.pendingHandoff?.task ?? "", /Goal: continue auth-refresh/); - assert.doesNotMatch(state.pendingHandoff?.task ?? "", /sensitive notebook body/); - assert.equal(state.pendingRequestedHandoff?.toolCalled, true); - assert.equal(typeof compactOptions?.onComplete, "function"); - assert.equal(result.content[0].text, "Handoff started."); - assert.equal(result.terminate, true); - - compactOptions.onComplete({}); - assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); -}); - -test("handoff compaction replaces old context with the queued task", async () => { - const pi = new MockPi(); - const state = createState(); - state.pendingHandoff = { task: "Goal: continue", source: "tool" }; - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true }; - state.activeNotebookTopic = "oauth"; - state.activeNotebookTopicSource = "human"; - registerHandoffCompaction(pi as any, state); - - const [handler] = pi.handlers.get("session_before_compact")!; - const result = await handler( - { - preparation: { tokensBefore: 123 }, - branchEntries: [{ id: "leaf-1" }], - }, - {}, - ); - - assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff, null); - assert.equal(state.activeNotebookTopic, null); - assert.equal(state.activeNotebookTopicSource, null); - assert.equal(result.compaction.summary, "Goal: continue"); - assert.equal(result.compaction.tokensBefore, 123); - assert.equal(result.compaction.firstKeptEntryId, "leaf-1-handoff-cut"); - assert.deepEqual(result.compaction.details, { handoff: true, task: "Goal: continue" }); -}); - -test("/handoff sets the handoff status indicator", async () => { - const pi = new MockPi(); - const state = createState(); - registerHandoffCommand(pi as any, state); - const statuses = new Map(); - - await pi.commands.get("handoff")!.handler("implement auth", { - hasUI: true, - isIdle: () => true, - ui: { - theme: { fg: (_name: string, text: string) => text }, - notify: () => {}, - setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, - }, - }); - - assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff in progress"); -}); - -test("handoff compaction clears the handoff status indicator", async () => { - const pi = new MockPi(); - const state = createState(); - state.pendingHandoff = { task: "Goal: continue", source: "tool" }; - registerHandoffCompaction(pi as any, state); - const statuses = new Map(); - const [handler] = pi.handlers.get("session_before_compact")!; - - await handler( - { preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-1" }] }, - { hasUI: true, ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } } }, - ); - - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); -}); - -test("handoff compaction error clears pending state and status", async () => { - const pi = new MockPi(); - const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; - registerHandoffTool(pi as any, state); - let compactOptions: any; - const statuses = new Map(); - - await pi.tools.get("handoff").execute( - "1", - { task: "Goal: continue" }, - undefined, - undefined, - { - hasUI: true, - ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } }, - compact: (options: any) => { compactOptions = options; }, - }, - ); - compactOptions.onError({}); - - assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff?.toolCalled, false); - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); -}); - -test("turn_end fallback clears stale requested handoff status", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const statuses = new Map(); - await pi.commands.get("handoff")!.handler("implement auth", { - hasUI: true, - isIdle: () => true, - ui: { - theme: { fg: (_name: string, text: string) => text }, - notify: () => {}, - setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, - }, - }); - - const [turnEnd] = pi.handlers.get("turn_end")!; - await turnEnd({}, { - hasUI: true, - ui: { - theme: { fg: (_name: string, text: string) => text }, - setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, - setWidget: () => {}, - }, - getContextUsage: () => null, - }); - - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); -}); - -test("session_start new clears stale handoff status and warning widget", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const statuses = new Map([[STATUS_KEY_HANDOFF, "stale"]]); - const widgets = new Map([[WIDGET_KEY_WARNING, ["stale"]]]); - const sessionStartHandlers = pi.handlers.get("session_start")!; - const ctx = { - hasUI: true, - ui: { - theme: { fg: (_name: string, text: string) => text }, - setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, - setWidget: (key: string, value: string[] | undefined) => { widgets.set(key, value); }, - }, - sessionManager: { getBranch: () => [] }, - getContextUsage: () => null, - }; - for (const sessionStart of sessionStartHandlers) { - await sessionStart({ reason: "new" }, ctx); - } - - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); - assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); -}); - -test("watchdog records context usage without user notifications", async () => { - const pi = new MockPi(); - const state = createState(); - registerWatchdog(pi as any, state); - const [handler] = pi.handlers.get("agent_end")!; - - const notifications: string[] = []; - await handler( - {}, - { - hasUI: true, - ui: { notify: (message: string) => notifications.push(message) }, - getContextUsage: () => ({ percent: 70 }), - }, - ); - - assert.equal(state.lastContextPercent, 70); - assert.deepEqual(notifications, []); -}); - -test("context injects watchdog reminder before each LLM call", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("context")!; - await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); - - const result = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - { - getContextUsage: () => ({ percent: 70 }), - }, - ); - - assert.equal(result.messages.length, 2); - assert.deepEqual(result.messages[0], { role: "user", content: "hi", timestamp: 1 }); - assert.equal(result.messages[1].role, "custom"); - assert.equal(result.messages[1].customType, "agenticoding-watchdog"); - assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /Context at 70%/); - assert.match(result.messages[1].content, /Active notebook topic: oauth/); - assert.match(result.messages[1].content, /spawn it instead of polluting the parent context/i); - assert.doesNotMatch(result.messages[1].content, /If you're mid-job and still clear|consider a handoff and draft a clear brief for what comes next/i); -}); - -test("context injects a boundary nudge below 30% after an explicit topic change", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("context")!; - await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); - await pi.commands.get("notebook")!.handler("billing", { hasUI: false, getContextUsage: () => null }); - - const result = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - { getContextUsage: () => ({ percent: 20 }) }, - ); - - assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /Notebook topic changed from oauth to billing/); -}); - - -test("context injects a no-topic nudge when context is high", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("context")!; - - const result = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - { getContextUsage: () => ({ percent: 70 }) }, - ); - - assert.equal(result.messages.length, 2); - assert.equal(result.messages[1].role, "custom"); - assert.equal(result.messages[1].customType, "agenticoding-watchdog"); - assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /No active notebook topic is set/); - assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i); -}); - - -test("context consumes a boundary hint after the first injected nudge", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("context")!; - await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); - await pi.commands.get("notebook")!.handler("billing", { hasUI: false, getContextUsage: () => null }); - - const first = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - { getContextUsage: () => ({ percent: 20 }) }, - ); - assert.match(first.messages[1].content, /Notebook topic changed from oauth to billing/); - - const second = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, - { getContextUsage: () => ({ percent: 20 }) }, - ); - assert.equal(second, undefined); -}); - - -test("buildNudge handles null percent and boundary hints before topic guidance", () => { - const boundary = buildNudge( - { - activeNotebookTopic: "oauth", - pendingTopicBoundaryHint: { from: "oauth", to: "billing", source: "human" }, - }, - null, - ); - assert.match(boundary, /Notebook topic changed from oauth to billing/); - assert.doesNotMatch(boundary, /Active notebook topic: oauth/); - - const noTopic = buildNudge({ activeNotebookTopic: null, pendingTopicBoundaryHint: null }, null); - assert.match(noTopic, /Topic-aware context reminder/); - assert.match(noTopic, /No active notebook topic is set/); -}); - -test("watchdog stays advisory when a requested handoff is not completed", async () => { - const pi = new MockPi(); - const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; - registerWatchdog(pi as any, state); - const [handler] = pi.handlers.get("agent_end")!; - - const notifications: string[] = []; - await handler( - {}, - { - hasUI: true, - ui: { - notify: (message: string) => notifications.push(message), - setStatus: () => {}, - }, - getContextUsage: () => ({ percent: 20 }), - }, - ); - - assert.equal(state.pendingRequestedHandoff, null); - assert.deepEqual(notifications, []); - assert.deepEqual(pi.sentUserMessages, []); -}); - -test("collapsed nested spawn render shows preview and stats", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix\nseven" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { - model: "mock-model", - thinking: "medium", - truncated: true, - stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, - }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("mock-model • medium"))); - assert.ok(lines.some((l: string) => l.includes("one"))); - assert.ok(lines.some((l: string) => l.includes("five"))); - assert.ok(lines.some((l: string) => l.includes("... 2 more lines"))); - assert.ok(lines.some((l: string) => l.includes("tok 12/34"))); - assert.ok(lines.some((l: string) => l.includes("trunc"))); -}); - -test("collapsed nested spawn render keeps all text blocks from the last assistant message", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "first" }, { type: "text", text: "second" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("first"))); - assert.ok(lines.some((l: string) => l.includes("second"))); -}); - -test("collapsed nested spawn truncation preserves shell background across preview and stats lines", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "Research the nudge on toggle off TODO from the readonly mode plan." }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { - model: "mock-model", - thinking: "medium", - truncated: true, - stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, - }, - }, - { expanded: false }, - ansiTheme, - createRenderContext(), - ) as any; - - const lines = component.render(24); - const previewLine = getRenderedLine(lines, plain => plain.includes("Research")); - const statsLine = getRenderedLine(lines, plain => plain.includes("tok 12/34")); - assertShellBackgroundPreserved(previewLine); - assertShellBackgroundPreserved(statsLine); - assert.match(stripAnsi(statsLine), /tok 12\/34/); -}); - -test("collapsed nested spawn keeps truncated stats line calm", () => { - const markerTheme = { - fg: (name: string, text: string) => `<${name}>${text}`, - bg: (_name: string, text: string) => text, - bold: (text: string) => text, - } as unknown as Theme; - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "short preview" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { - model: "mock-model", - thinking: "medium", - truncated: true, - stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, - }, - }, - { expanded: false }, - markerTheme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - const statsLine = getLineContaining(lines, "tok 12/34"); - assert.match(statsLine, /.*tok 12\/34.*trunc.*<\/dim>/); - assert.equal(statsLine.includes(""), false); -}); - -test("nested spawn render is safe without details", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }] }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("hello"))); -}); - -test("expanded nested spawn header stays within width after indent", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "model-name", thinking: "medium", truncated: false }, - }, - { expanded: true }, - theme, - createRenderContext({ expanded: true }), - ) as any; - - const lines = component.render(24); - const headerLine = lines.find((line: string) => line.includes("model-name")) ?? ""; - assert.ok(headerLine.startsWith(" ")); - assert.ok(stripAnsi(headerLine).length <= 24); -}); - -test("nested spawn clears cached render when showImages changes", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }, { type: "image", data: "iVBOR", mimeType: "image/png" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false }, - }, - { expanded: true }, - theme, - createRenderContext({ expanded: true, showImages: true }), - ) as any; - const linesWithImages = component.render(120); - - const sameComponent = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false }, - }, - { expanded: true }, - theme, - createRenderContext({ expanded: true, showImages: false, lastComponent: component }), - ) as any; - const linesWithoutImages = sameComponent.render(120); - - assert.equal(sameComponent, component); - // Both render calls produce valid output — cache invalidation is verified - // implicitly because the second output reflects the showImages change - // rather than returning stale cached content from the first call. - assert.ok(Array.isArray(linesWithImages)); - assert.ok(Array.isArray(linesWithoutImages)); -}); - -test("nested spawn rerenders when stats become unavailable", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - const before = component.render(120); - assert.equal(before.some((l: string) => l.includes("stats unavailable")), false); - - const sameComponent = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false, outcome: "success", statsUnavailable: true }, - }, - { expanded: false }, - theme, - createRenderContext({ lastComponent: component }), - ) as any; - const after = sameComponent.render(120); - - assert.equal(sameComponent, component); - assert.ok(after.some((l: string) => l.includes("stats unavailable"))); - assert.equal(after.some((l: string) => l.includes("initializing")), false); -}); - -test("agentic e2e spawn child can use active registered non-builtin tool", async () => { - const tempRoot = await mkdtemp(join(tmpdir(), "pi-agenticoding-a10-")); - const tempCwd = join(tempRoot, "project"); - const tempAgentDir = join(tempRoot, "agent"); - const extensionDir = join(tempCwd, ".pi", "extensions"); - const sentinel = "AGENTIC_E2E_PROBE_OK"; - const oldAgentDir = process.env.PI_CODING_AGENT_DIR; - const oldOpenAiApiKey = process.env.OPENAI_API_KEY; - const parentRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); - let streamCallCount = 0; - - try { - await mkdir(extensionDir, { recursive: true }); - await mkdir(tempAgentDir, { recursive: true }); - await writeFile(join(tempCwd, "package.json"), JSON.stringify({ type: "module" })); - await writeFile( - join(extensionDir, "agentic-e2e-probe.js"), - ` -export default function(pi) { - pi.registerTool({ - name: "agentic_e2e_probe", - label: "Agentic E2E Probe", - description: "Return the deterministic Story 04 A10 sentinel.", - promptSnippet: "Call agentic_e2e_probe to return the Story 04 A10 sentinel.", - parameters: { type: "object", properties: {}, additionalProperties: false }, - async execute() { - globalThis.__agenticE2eProbeCalls = (globalThis.__agenticE2eProbeCalls ?? 0) + 1; - return { - content: [{ type: "text", text: "${sentinel}" }], - details: { sentinel: "${sentinel}" }, - }; - }, - }); -} -`, - ); - - process.env.PI_CODING_AGENT_DIR = tempAgentDir; - process.env.OPENAI_API_KEY = "test-openai-key"; - (globalThis as any).__agenticE2eProbeCalls = 0; - - parentRegistry.registerProvider("openai", { - name: "Agentic E2E OpenAI-compatible provider", - api: "agentic-e2e-api", - apiKey: "test-openai-key", - baseUrl: "http://localhost:0", - streamSimple: (model: any, context: any) => { - streamCallCount += 1; - if (streamCallCount === 1) { - const promptText = context.messages.map(messageText).join("\n"); - assert.match(promptText, /agentic_e2e_probe/); - assert.match(promptText, new RegExp(sentinel)); - return createTestAssistantStream(createTestAssistantMessage(model, [ - { type: "toolCall", id: "probe-call-1", name: "agentic_e2e_probe", arguments: {} }, - ], "tool_calls")); - } - - const probeResult = context.messages.find((message: any) => - message.role === "toolResult" && - message.toolName === "agentic_e2e_probe" && - messageText(message).includes(sentinel) - ); - const text = probeResult ? sentinel : "AGENTIC_E2E_PROBE_MISSING"; - return createTestAssistantStream(createTestAssistantMessage(model, [{ type: "text", text }])); - }, - models: [{ - id: "agentic-e2e-model", - name: "Agentic E2E Model", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 1024, - }], - }); - const model = parentRegistry.find("openai", "agentic-e2e-model"); - assert.ok(model); - - const pi = new MockPi(); - pi.setToolSource("agentic_e2e_probe", "project"); - pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]); - pi.setAllTools(["read", "agentic_e2e_probe", "spawn"]); - const state = createState(); - const childPrompt = `Use the agentic_e2e_probe tool and return ${sentinel}.`; - - registerSpawnTool(pi as any, state); - const result = await pi.tools.get("spawn").execute( - "spawn-e2e", - { prompt: childPrompt, thinking: "medium" }, - undefined, - undefined, - { model, cwd: tempCwd }, - ); - - assert.equal(result.content[0].text, sentinel); - assert.equal((globalThis as any).__agenticE2eProbeCalls, 1); - assert.equal(streamCallCount, 2); - } finally { - parentRegistry.unregisterProvider("openai"); - if (oldAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = oldAgentDir; - } - if (oldOpenAiApiKey === undefined) { - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = oldOpenAiApiKey; - } - delete (globalThis as any).__agenticE2eProbeCalls; - await rm(tempRoot, { recursive: true, force: true }); - } -}); - -test("spawn execute passes broad active registered tool formula to child session", async () => { - const pi = new MockPi(); - pi.setToolSource("project_search", "project"); - pi.setToolSource("inactive_registered", "extension"); - pi.setActiveTools(["read", "bash", "spawn", "handoff", "project_search", "phantom_tool"]); - pi.setAllTools(["read", "bash", "spawn", "handoff", "project_search", "inactive_registered"]); - const state = createState(); - - let seenConfig: any; - const mockFactory = async (config: any) => { - seenConfig = config; - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task", thinking: "high" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - assert.equal(seenConfig.model.id, "mock-model"); - assert.equal(seenConfig.thinkingLevel, "high"); - assert.equal(seenConfig.cwd, "/tmp"); - assert.deepEqual( - new Set(seenConfig.tools), - new Set(["read", "bash", "project_search", "notebook_write", "notebook_read", "notebook_index"]), - ); - assert.deepEqual(seenConfig.customTools.map((tool: any) => tool.name), ["notebook_write", "notebook_read", "notebook_index"]); -}); - -test("spawn execute builds prompt with notebook pages and task", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - state.notebookPages.set("entry-a", "preview line\nfull body"); - - let seenPrompt = ""; - const mockFactory = async (config: any) => { - const session = { - messages: [] as any[], - prompt: async (prompt: string) => { - seenPrompt = prompt; - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - // Verify user-facing invariants: task text is included, notebook pages are referenced - assert.match(seenPrompt, /Do the task/); - assert.match(seenPrompt, /entry-a: preview line/); -}); - -test("spawn renderResult falls back to static text when no live session is stored", () => { - const state = createState(); - const pi = new MockPi(); - registerSpawnTool(pi as any, state); - - const result = pi.tools.get("spawn").renderResult( - { - content: [{ type: "text", text: "fallback output" }], - details: { model: "m", thinking: "low", truncated: false }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = result.render(120); - assert.ok(lines.some((l: string) => l.includes("m • low"))); - assert.ok(lines.some((l: string) => l.includes("fallback output"))); -}); - -test("spawn renderResult distinguishes aborted and error outcomes", () => { - const state = createState(); - const pi = new MockPi(); - registerSpawnTool(pi as any, state); - - const aborted = pi.tools.get("spawn").renderResult( - { - content: [{ type: "text", text: "stopped" }], - details: { model: "m", thinking: "low", truncated: false, outcome: "aborted" }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - const error = pi.tools.get("spawn").renderResult( - { - content: [{ type: "text", text: "failed" }], - details: { model: "m", thinking: "low", truncated: false, outcome: "error" }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const abortedLines = aborted.render(120); - const errorLines = error.render(120); - assert.ok(abortedLines.some((l: string) => l.includes("✗ m • low"))); - assert.ok(abortedLines.some((l: string) => l.includes("aborted"))); - assert.ok(errorLines.some((l: string) => l.includes("⚠ m • low"))); - assert.ok(errorLines.some((l: string) => l.includes("error"))); -}); - -test("spawn execute returns result and stats", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - const updates: any[] = []; - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => ({ - tokens: { input: 11, output: 22, cacheRead: 3, cacheWrite: 4, total: 40 }, - cost: 0.5, - assistantMessages: 2, - }), - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - const result = await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task", thinking: "high" }, - undefined, - (update: any) => updates.push(update), - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - assert.deepEqual(updates, [{ - content: [], - details: { model: "mock-model", thinking: "high", truncated: false, outcome: "running" }, - }]); - assert.equal(result.content[0].text, "child result"); - assert.equal(result.details.outcome, "success"); - assert.deepEqual(result.details.stats, { - inputTokens: 11, - outputTokens: 22, - cacheReadTokens: 3, - cacheWriteTokens: 4, - totalTokens: 40, - cost: 0.5, - turns: 2, - }); -}); - -test("spawn execute marks stats unavailable when stats collection throws", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => { - warnings.push(args); - }; - - try { - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => { - throw new Error("stats failed"); - }, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - const result = await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - assert.equal(result.details.stats, undefined); - assert.equal(result.details.statsUnavailable, true); - assert.equal(warnings.length, 1); - assert.match(String(warnings[0][1]), /stats failed/); - assert.equal(warnings[0][2], "spawn-1"); - } finally { - console.warn = originalWarn; - } -}); - -test("spawn execute throws when child produces no output", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => {}, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - await assert.rejects( - () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }), - /Child agent produced no output\./, - ); -}); - -test("spawn execute clears childSessions when prompt throws", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - throw new Error("prompt failed"); - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - await assert.rejects( - () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }), - /prompt failed/, - ); - assert.equal(state.childSessions.size, 0); -}); - -test("spawn execute clears childSessions after successful completion when unrendered", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - const result = await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - assert.equal(result.content[0].text, "child result"); - assert.equal(state.childSessions.size, 0); -}); - -test("spawn execute fails explicitly without a configured model", async () => { - const pi = new MockPi(); - const state = createState(); - registerSpawnTool(pi as any, state); - await assert.rejects( - () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { cwd: "/tmp" }), - /No model configured\. Cannot spawn child agent\./, - ); -}); - -test("child tool names inherit active registered builtins and exclude recursive controls", () => { - const state = createState(); - const childTools = createChildTools(new MockPi() as any, state); - assert.equal(childTools.some(t => t.name === "spawn"), false); - const childToolNames = buildChildToolNames( - ["read", "bash", "spawn", "handoff", "future_tool"], - childTools, - [ - { name: "read", sourceInfo: { source: "builtin" } }, - { name: "bash", sourceInfo: { source: "builtin" } }, - { name: "spawn", sourceInfo: { source: "builtin" } }, - { name: "handoff", sourceInfo: { source: "builtin" } }, - { name: "future_tool", sourceInfo: { source: "project" } }, - ] as any, - ); - assert.equal(childToolNames.includes("read"), true); - assert.equal(childToolNames.includes("bash"), true); - assert.equal(childToolNames.includes("spawn"), false); - assert.equal(childToolNames.includes("handoff"), false); -}); - -test("spawn renderResult transfers session ownership out of shared state", () => { - const state = createState(); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const pi = new MockPi(); - registerSpawnTool(pi as any, state); - - const component = pi.tools.get("spawn").renderResult( - { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - assert.equal(state.childSessions.has("tool-call-1"), false); - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("hello"))); -}); - -test("spawn renderResult reuses lastComponent", () => { - const state = createState(); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const pi = new MockPi(); - registerSpawnTool(pi as any, state); - - const first = pi.tools.get("spawn").renderResult( - { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ); - const second = pi.tools.get("spawn").renderResult( - { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ lastComponent: first }), - ); - assert.equal(first, second); -}); - -test("resetState aborts and clears child session registries", () => { - const state = createState(); - let abortCalls = 0; - const session = { - ...createSession([]), - abort: async () => { - abortCalls++; - }, - } as any; - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - resetState(state); - assert.equal(abortCalls, 1); - assert.equal(state.childSessions.size, 0); - assert.equal(state.liveChildSessions.size, 0); -}); - -test("resetState aborts a claimed child session after render ownership transfer", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - let abortCalls = 0; - const session = { - ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), - abort: async () => { - abortCalls++; - }, - } as any; - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ); - - assert.equal(state.childSessions.has("tool-call-1"), false); - assert.equal(state.liveChildSessions.has("tool-call-1"), true); - - resetState(state); - - assert.equal(abortCalls, 1); - assert.equal(state.childSessions.size, 0); - assert.equal(state.liveChildSessions.size, 0); -}); - -test("executeSpawn suppresses stale child sessions after resetState during async setup", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - let resolveFactory!: (value: any) => void; - const factoryReady = new Promise((resolve) => { - resolveFactory = resolve; - }); - let promptCalled = false; - let abortCalls = 0; - let onUpdateCalled = false; - const staleSession = { - messages: [] as any[], - prompt: async () => { - promptCalled = true; - staleSession.messages = [{ role: "assistant", content: [{ type: "text", text: "stale result" }] }]; - }, - abort: async () => { - abortCalls++; - }, - getSessionStats: () => undefined, - }; - - const executePromise = executeSpawn( - "spawn-1", - pi as any, - { model: { id: "mock-model" }, cwd: "/tmp" } as any, - state, - { prompt: "Do the task" }, - undefined, - () => { - onUpdateCalled = true; - }, - "medium", - async () => factoryReady, - ); - - resetState(state); - const freshSession = createSession([{ role: "assistant", content: [{ type: "text", text: "fresh result" }] }]); - state.childSessions.set("spawn-1", freshSession); - state.liveChildSessions.set("spawn-1", freshSession); - resolveFactory({ session: staleSession as any }); - - await assert.rejects(() => executePromise, /invalidated by reset/i); - assert.equal(onUpdateCalled, false); - assert.equal(promptCalled, false); - assert.equal(abortCalls, 1); - assert.equal(state.childSessions.get("spawn-1"), freshSession); - assert.equal(state.liveChildSessions.get("spawn-1"), freshSession); -}); - -test("child tool names inherit active registered MCP extension tools", () => { - const state = createState(); - const childTools = createChildTools(new MockPi() as any, state); - - const toolNames = buildChildToolNames( - ["read", "chunkhound_code_research", "mcp_status"], - childTools, - [ - { name: "read", sourceInfo: { source: "builtin" } }, - { name: "chunkhound_code_research", sourceInfo: { source: "extension" } }, - { name: "mcp_status", sourceInfo: { source: "extension" } }, - ] as any, - ); - - assert.equal(toolNames.includes("chunkhound_code_research"), true); - assert.equal(toolNames.includes("mcp_status"), true); -}); - -test("child tool names inherit active registered project package and local extension tools", () => { - const state = createState(); - const childTools = createChildTools(new MockPi() as any, state); - - const toolNames = buildChildToolNames( - ["project_search", "package_lint", "local_helper"], - childTools, - [ - { name: "project_search", sourceInfo: { source: "project" } }, - { name: "package_lint", sourceInfo: { source: "package" } }, - { name: "local_helper", sourceInfo: { source: "local" } }, - ] as any, - ); - - assert.equal(toolNames.includes("project_search"), true); - assert.equal(toolNames.includes("package_lint"), true); - assert.equal(toolNames.includes("local_helper"), true); -}); - -test("child tool names exclude inactive registered and active phantom tools", () => { - const state = createState(); - const childTools = createChildTools(new MockPi() as any, state); - - const toolNames = buildChildToolNames( - ["read", "active_phantom"], - childTools, - [ - { name: "read", sourceInfo: { source: "builtin" } }, - { name: "inactive_registered", sourceInfo: { source: "extension" } }, - ] as any, - ); - - assert.equal(toolNames.includes("read"), true); - assert.equal(toolNames.includes("inactive_registered"), false); - assert.equal(toolNames.includes("active_phantom"), false); - assert.ok(toolNames.includes("notebook_write")); - assert.ok(toolNames.includes("notebook_read")); - assert.ok(toolNames.includes("notebook_index")); - assert.equal(toolNames.includes("handoff"), false); - assert.equal(toolNames.includes("spawn"), false); -}); - -function createSubscribableSession(messages: any[] = []) { - let handler: ((event: any) => void) | undefined; - return { - session: { - messages, - subscribe: (cb: (event: any) => void) => { - handler = cb; - return () => { handler = undefined; }; - }, - getToolDefinition: () => undefined, - sessionManager: { getCwd: () => process.cwd() }, - abort: async () => {}, - } as unknown as import("@earendil-works/pi-coding-agent").AgentSession, - emit: (event: any) => handler?.(event), - }; -} - -test("nested spawn live action tracks tool execution events", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - // Mock console.warn to suppress any expected-but-harmless warnings - // (e.g., streaming component errors in headless test env). - const originalWarn = console.warn; - console.warn = () => {}; - - try { - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // message_start → thinking - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - let lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking, got: ${lines.join("\n")}`); - - // message_update with text → live preview - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "writing code now" }] } }); - lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("writing code now")), `expected live text preview, got: ${lines.join("\n")}`); - - // message_end → success marker in identity line - emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "summary" }], stopReason: "end_turn" } }); - lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("✅")), `expected success marker, got: ${lines.join("\n")}`); - - // Tool events degrade gracefully in minimal test env and still update live action - emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); - lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("[bash]")), `expected tool live action, got: ${lines.join("\n")}`); - } finally { - console.warn = originalWarn; - } -}); - -test("nested spawn handleEvent recovers from malformed events", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => warnings.push(args); - - try { - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // Emit a malformed event that will throw inside handleEvent - emit({ type: "message_start", message: null }); - assert.equal(warnings.length, 1); - assert.match(String(warnings[0][1]), /message_start/); - - // Subsequent valid events still process - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking after recovery, got: ${lines.join("\n")}`); - } finally { - console.warn = originalWarn; - } -}); - -test("nested spawn message_end with aborted stopReason clears pending tools", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // Start an assistant message - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - // End it with aborted — sets lastAction to "aborted" - emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "partial" }], stopReason: "aborted", errorMessage: "killed" } }); - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("aborted")), `expected aborted, got: ${lines.join("\n")}`); -}); - -test("nested spawn dispose stops event processing", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - component.dispose(); - - // Emit event after dispose — should not update state or crash - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - const after = component.render(120); - - assert.ok(after.every((line: string) => !line.includes("thinking")), `unexpected post-dispose update: ${after.join("\n")}`); -}); - -test("nested spawn dispose aborts a claimed live child session", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - let abortCalls = 0; - const session = { - ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), - abort: async () => { - abortCalls++; - }, - } as any; - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - assert.equal(state.childSessions.has("tool-call-1"), false); - assert.equal(state.liveChildSessions.has("tool-call-1"), true); - - component.dispose(); - - assert.equal(abortCalls, 1); - assert.equal(state.liveChildSessions.has("tool-call-1"), false); -}); - -test("spawn execute short-circuits when signal is already aborted", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - let abortCalled = false; - let promptCalled = false; - let onUpdateCalled = false; - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - promptCalled = true; - }, - abort: async () => { abortCalled = true; }, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - const controller = new AbortController(); - controller.abort(); - - await assert.rejects( - () => pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task" }, - controller.signal, - () => { onUpdateCalled = true; }, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ), - /abort/i, - ); - - assert.equal(abortCalled, true); - assert.equal(promptCalled, false); - assert.equal(onUpdateCalled, false); - assert.equal(state.childSessions.size, 0); - assert.equal(state.liveChildSessions.size, 0); -}); - -test("spawn execute truncates very long child output", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - // Generate > 2000 lines of output - const longText = Array.from({ length: 2100 }, (_, i) => `Line ${i + 1}`).join("\n"); - - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: longText }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - const result = await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Generate lots of output" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - assert.equal(result.details.truncated, true); - assert.ok(result.content[0].text.includes("[Result truncated")); - assert.equal(state.liveChildSessions.size, 0); -}); - -test("spawn execute truncates child output by byte limit", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - const longText = "🙂".repeat(20_000); - - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: longText }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - const result = await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Generate byte-heavy output" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - assert.equal(result.details.truncated, true); - assert.ok(result.content[0].text.includes("[Result truncated")); - assert.ok(result.content[0].text.length < longText.length); - assert.equal(result.content[0].text.includes("\n"), true); -}); - -test("spawn execute tells children when no notebook pages exist", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - let promptText = ""; - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async (text: string) => { - promptText = text; - session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - assert.match(promptText, /No notebook pages\./); - assert.doesNotMatch(promptText, /Available notebook pages:/); - assert.match(promptText, /store only durable grounding knowledge for future contexts/i); - assert.match(promptText, /Keep transient task state in your final reply to the parent\./); -}); - -test("executeSpawn → onUpdate → renderResult chains session ownership", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - let onUpdateCalled = false; - let renderComponent: any = null; - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - const executePromise = pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task" }, - undefined, - (update: any) => { - onUpdateCalled = true; - // Simulate pi rendering during execution by calling renderResult - // with the same toolCallId the execute call is using. - renderComponent = pi.tools.get("spawn").renderResult( - { content: [], details: update.details }, - { expanded: false }, - theme, - { toolCallId: "spawn-1", expanded: false, showImages: true, lastComponent: undefined, invalidate: () => {} }, - ); - }, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - const result = await executePromise; - - // onUpdate was called - assert.equal(onUpdateCalled, true); - - // renderComponent from onUpdate has a live session attached - assert.equal(typeof renderComponent.hasSession, "function"); - assert.equal(renderComponent.hasSession(), true); - - // Session ownership was transferred out of the render handoff queue - assert.equal(state.childSessions.has("spawn-1"), false); - assert.equal(state.liveChildSessions.has("spawn-1"), false); - - // Component renders session content - const lines = renderComponent.render(120); - const text = lines.join(" "); - assert.ok(text.includes("result"), `expected result in render, got: ${text}`); - - // Final execute result is also correct - assert.equal(result.content[0].text, "result"); -}); - -test("spawn render shows success state when stats are unavailable", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "final summary" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false, outcome: "success", statsUnavailable: true }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("✅ mock-model • medium"))); - assert.ok(lines.some((l: string) => l.includes("stats unavailable"))); - assert.equal(lines.some((l: string) => l.includes("initializing")), false); -}); - -test("spawn execute aborts child session when signal fires during execution", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - let abortCalled = false; - let resolvePrompt!: () => void; - let promptStarted!: () => void; - const started = new Promise((resolve) => { promptStarted = resolve; }); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - promptStarted(); - await new Promise((resolve) => { resolvePrompt = resolve; }); - session.messages = [{ role: "assistant", content: [{ type: "text", text: "aborted mid-flight" }] }]; - }, - abort: async () => { - abortCalled = true; - resolvePrompt(); - }, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - const controller = new AbortController(); - const executePromise = pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task" }, - controller.signal, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - await started; - controller.abort(); - - const result = await executePromise; - assert.equal(abortCalled, true); - assert.equal(state.childSessions.size, 0); - assert.equal(state.liveChildSessions.size, 0); - assert.equal(result.content[0].text, "aborted mid-flight"); - assert.equal(result.details.outcome, "aborted"); -}); - -test("spawn renderCall shows prompt preview and thinking level", () => { - const state = createState(); - const pi = new MockPi(); - registerSpawnTool(pi as any, state); - - const tool = pi.tools.get("spawn"); - - // Collapsed: short prompt - const collapsed = tool.renderCall({ prompt: "Do X" }, theme, { expanded: false }); - const collapsedLines = collapsed.render(120); - assert.ok(collapsedLines.some((l: string) => l.includes("spawn"))); - assert.ok(collapsedLines.some((l: string) => l.includes("Do X"))); - - // Collapsed: long prompt shows truncation hint - const longPrompt = Array.from({ length: 10 }, (_, i) => `Line ${i}`).join("\n"); - const truncated = tool.renderCall({ prompt: longPrompt }, theme, { expanded: false }); - const truncatedLines = truncated.render(120); - assert.ok(truncatedLines.some((l: string) => l.includes("more lines"))); - - // With thinking level - const withThinking = tool.renderCall({ prompt: "Do X", thinking: "high" }, theme, { expanded: false }); - const thinkingLines = withThinking.render(120); - assert.ok(thinkingLines.some((l: string) => l.includes("high"))); - - // Expanded: shows full prompt - const expanded = tool.renderCall({ prompt: longPrompt }, theme, { expanded: true }); - const expandedLines = expanded.render(120); - assert.ok(!expandedLines.some((l: string) => l.includes("more lines"))); -}); - - - -test("notebook rehydration rebuilds the latest epoch and enables notebook tools", async () => { - const pi = new MockPi(); - const state = createState(); - registerNotebookRehydration(pi as any, state); - const [handler] = pi.handlers.get("session_start")!; - - await handler( - {}, - { - sessionManager: { - getBranch: () => [ - { type: "custom", customType: "ledger-entry", data: { epoch: 1, name: "old", content: "old" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 2, name: "keep", content: "new" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 2, name: "keep", content: "newer" } }, - ], - }, - }, - ); - - assert.equal(state.epoch, 2); - assert.deepEqual(Array.from(state.notebookPages.entries()), [["keep", "newer"]]); - assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); -}); - - -test("notebook rehydration rebuilds from the latest persisted epoch and avoids duplicate active tools", async () => { - const pi = new MockPi(); - pi.activeTools = ["read", "notebook_read", "notebook_index"]; - const state = createState(); - state.epoch = 7; - registerNotebookRehydration(pi as any, state); - const [handler] = pi.handlers.get("session_start")!; - - await handler( - {}, - { - sessionManager: { - getBranch: () => [ - { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 7, name: "keep", content: "fresh" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "future", content: "latest" } }, - ], - }, - }, - ); - - assert.equal(state.epoch, 8); - assert.deepEqual(Array.from(state.notebookPages.entries()), [["future", "latest"]]); - assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); -}); - - -test("notebook rehydration clears stale in-memory notebook state when persisted history is empty", async () => { - const pi = new MockPi(); - const state = createState(); - state.epoch = 7; - state.notebookPages.set("stale", "stale body"); - registerNotebookRehydration(pi as any, state); - const [handler] = pi.handlers.get("session_start")!; - - await handler( - {}, - { - sessionManager: { - getBranch: () => [], - }, - }, - ); - - assert.equal(state.epoch, 0); - assert.deepEqual(Array.from(state.notebookPages.entries()), []); - assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); -}); - - -test("session_start rehydrates the latest persisted notebook state through the full hook chain", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - pi.activeTools = ["read", "notebook_read"]; - registerAgenticoding(pi as any); - - try { - const notebookWrite = pi.tools.get("notebook_write"); - await notebookWrite.execute( - "seed", - { name: "stale-page", content: "stale body" }, - undefined, - undefined, - makeTUICtx({ hasUI: false }), - ); - - const sessionStartHandlers = pi.handlers.get("session_start")!; - const ctx = { - hasUI: false, - getContextUsage: () => null, - sessionManager: { - getBranch: () => [ - { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "fresh" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "newer" } }, - ], - }, - }; - for (const sessionStart of sessionStartHandlers) { - await sessionStart({ reason: "resume" }, ctx as any); - } - - const notebookIndex = pi.tools.get("notebook_index"); - const notebookRead = pi.tools.get("notebook_read"); - const indexResult = await notebookIndex.execute("1", {}, undefined, undefined, {} as any); - assert.deepEqual(indexResult.details.entries, ["keep"]); - - const readResult = await notebookRead.execute("2", { name: "keep" }, undefined, undefined, {} as any); - assert.equal(readResult.details.found, true); - assert.equal(readResult.details.body, "newer"); - assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); - } finally { - resetNotebookWriteLock(); - } -}); - -test("notebook tools add/get/list return stable contract details", async () => { - const pi = new MockPi(); - const state = createState(); - const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); - - const addResult = await notebookWrite.execute("1", { name: "entry-a", content: "first line\nsecond line" }, undefined, undefined, {} as any); - assert.deepEqual(addResult.details, { entries: ["entry-a"], preview: "first line" }); - assert.equal(state.notebookPages.get("entry-a"), "first line\nsecond line"); - assert.equal(pi.appendedEntries.length, 1); - assert.equal(pi.appendedEntries[0].customType, "notebook-entry"); - assert.equal(pi.appendedEntries[0].data.name, "entry-a"); - - const getResult = await notebookRead.execute("2", { name: "entry-a" }, undefined, undefined, {} as any); - assert.equal(getResult.details.found, true); - assert.deepEqual(getResult.details.entries, ["entry-a"]); - assert.match(getResult.content[0].text, /--- entry-a ---/); - assert.match(getResult.content[0].text, /second line/); - - const listResult = await notebookIndex.execute("3", {}, undefined, undefined, {} as any); - assert.deepEqual(listResult.details, { entries: ["entry-a"] }); - assert.match(listResult.content[0].text, /entry-a: first line/); -}); - -test("child notebook tools reject stale access after reset", async () => { - const pi = new MockPi(); - const state = createState(); - state.notebookPages.set("entry-a", "alpha"); - let stale = false; - const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state, { isStale: () => stale }); - - stale = true; - await assert.rejects( - () => notebookWrite.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any), - /invalidated by reset/i, - ); - await assert.rejects( - () => notebookRead.execute("2", { name: "entry-a" }, undefined, undefined, {} as any), - /invalidated by reset/i, - ); - await assert.rejects( - () => notebookIndex.execute("3", {}, undefined, undefined, {} as any), - /invalidated by reset/i, - ); - assert.equal(state.notebookPages.get("entry-a"), "alpha"); - assert.equal(pi.appendedEntries.length, 0); -}); - -test("child notebook_write succeeds while child session is fresh", async () => { - const pi = new MockPi(); - const state = createState(); - const [notebookWrite] = createNotebookToolDefinitions(pi as any, state, { isStale: () => false }); - - const result = await notebookWrite.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any); - assert.deepEqual(result.details, { entries: ["entry-a"], preview: "alpha" }); - assert.equal(state.notebookPages.get("entry-a"), "alpha"); - assert.equal(pi.appendedEntries.length, 1); -}); - -test("notebook_read reports not found with current page names", async () => { - const pi = new MockPi(); - const state = createState(); - state.notebookPages.set("entry-a", "alpha"); - state.notebookPages.set("entry-b", "beta"); - const [, notebookRead] = createNotebookToolDefinitions(pi as any, state); - - const result = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); - assert.deepEqual(result.details, { entries: ["entry-a", "entry-b"], found: false }); - assert.match(result.content[0].text, /Notebook page "missing" not found\./); - assert.match(result.content[0].text, /Notebook Pages:\n/); - assert.match(result.content[0].text, /entry-a: alpha/); - assert.match(result.content[0].text, /entry-b: beta/); -}); - -test("notebook tools show empty-state placeholders", async () => { - const pi = new MockPi(); - const state = createState(); - const [, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); - - const missing = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); - assert.deepEqual(missing.details, { entries: [], found: false }); - assert.match(missing.content[0].text, /Notebook Pages:\n\(empty\)/); - - const list = await notebookIndex.execute("2", {}, undefined, undefined, {} as any); - assert.deepEqual(list.details, { entries: [] }); - assert.match(list.content[0].text, /Notebook Pages:\n\(empty\)/); -}); - -test("notebook_write pushes onUpdate and refreshes UI indicators", async () => { - const pi = new MockPi(); - const state = createState(); - const [notebookWrite] = createNotebookToolDefinitions(pi as any, state); - const record = { statuses: new Map(), widgets: new Map() }; - let update: any; - - const result = await notebookWrite.execute( - "1", - { name: "entry-a", content: "first line\nsecond line" }, - undefined, - (payload: any) => { update = payload; }, - makeTUICtx({ percent: 42, record }), - ); - - assert.equal(update.content[0].text, 'Saved "entry-a": first line'); - assert.deepEqual(update.details, { entries: ["entry-a"], preview: "first line" }); - assert.equal(record.statuses.get("agenticoding-notebook"), "📒 1"); - assert.deepEqual(result.details, { entries: ["entry-a"], preview: "first line" }); -}); - -test("notebook tool renderers expose stable call/result summaries", async () => { - const pi = new MockPi(); - const state = createState(); - const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); - - const addCall = notebookWrite.renderCall!({ name: "entry-a", content: "first line\nsecond line" }, theme, {} as any) as Text; - assert.match(stripAnsi(addCall.render(120).join("\n")), /notebook_write "entry-a": first line/); - - const addResult = notebookWrite.renderResult!( - { content: [{ type: "text", text: "" }], details: { entries: ["entry-a"], preview: "first line" } }, - { expanded: true }, - theme, - { args: { name: "entry-a", content: "first line\nsecond line" } } as any, - ) as Text; - assert.match(stripAnsi(addResult.render(120).join("\n")), /Saved "entry-a": first line/); - assert.match(stripAnsi(addResult.render(120).join("\n")), /entry-a/); - - const getResult = notebookRead.renderResult!( - { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "body" } }, - { expanded: true }, - theme, - { args: { name: "entry-a" } } as any, - ) as Text; - assert.match(stripAnsi(getResult.render(120).join("\n")), /"entry-a"/); - assert.match(stripAnsi(getResult.render(120).join("\n")), /body/); - - const getResultWithDelimiters = notebookRead.renderResult!( - { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "line 1\n---\nline 2" } }, - { expanded: true }, - theme, - { args: { name: "entry-a" } } as any, - ) as Text; - assert.match(stripAnsi(getResultWithDelimiters.render(120).join("\n")), /line 1/); - assert.match(stripAnsi(getResultWithDelimiters.render(120).join("\n")), /line 2/); - - const listResult = notebookIndex.renderResult!( - { content: [{ type: "text", text: "" }], details: { entries: ["entry-a", "entry-b"] } }, - { expanded: true }, - theme, - {} as any, - ) as Text; - assert.match(stripAnsi(listResult.render(120).join("\n")), /2 pages/); - assert.match(stripAnsi(listResult.render(120).join("\n")), /entry-a/); - assert.match(stripAnsi(listResult.render(120).join("\n")), /entry-b/); -}); - -test("/notebook exits cleanly when headless", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - - await assert.doesNotReject(() => pi.commands.get("notebook")!.handler("", { hasUI: false })); -}); - - -test("/notebook notifies with info on first set and warning on boundary change", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const notifications: Array<{ message: string; level: string }> = []; - const statuses = new Map(); - const widgets = new Map(); - const ctx = { - hasUI: true, - getContextUsage: () => ({ percent: 20 }), - ui: { - theme: { fg: (_name: string, text: string) => text }, - notify: (message: string, level: string) => { notifications.push({ message, level }); }, - setStatus: (key: string, status: string | undefined) => { statuses.set(key, status); }, - setWidget: (key: string, content: string[] | undefined) => { widgets.set(key, content); }, - }, - }; - - await pi.commands.get("notebook")!.handler("oauth", ctx as any); - await pi.commands.get("notebook")!.handler("billing", ctx as any); - - assert.deepEqual(notifications[0], { message: "Active notebook topic: oauth", level: "info" }); - assert.match(notifications[1].message, /Active notebook topic changed: oauth → billing/); - assert.equal(notifications[1].level, "warning"); - assert.equal(statuses.get(STATUS_KEY_TOPIC), "🧭 billing"); - assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); -}); - -test("/notebook empty overlay renders empty state and closes on input", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - let overlay: any; - let doneCalls = 0; - - await pi.commands.get("notebook")!.handler("", { - hasUI: true, - ui: { - theme, - custom: async (build: any) => { - overlay = build({ requestRender: () => {} }, theme, {}, () => { doneCalls++; }); - }, - }, - }); - - const lines = stripAnsi(overlay.render(120).join("\n")); - assert.match(lines, /Notebook \(0 pages\)/); - assert.match(lines, /\(empty\) — use notebook_write to create pages/); - overlay.handleInput("x"); - assert.equal(doneCalls, 1); -}); - -test("/notebook selection previews the chosen entry", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const notifications: string[] = []; - const notebookWrite = pi.tools.get("notebook_write"); - await notebookWrite.execute("1", { name: "alpha", content: "body line\nsecond line" }, undefined, undefined, makeTUICtx()); - let overlay: any; - let doneCalls = 0; - - await pi.commands.get("notebook")!.handler("", { - hasUI: true, - ui: { - theme, - custom: async (build: any) => { - overlay = build({ requestRender: () => {} }, theme, {}, () => { doneCalls++; }); - }, - notify: (message: string) => { notifications.push(message); }, - }, - }); - - // First Enter selects the entry — shows body inline, done() not yet called - overlay.handleInput("\r"); - assert.equal(doneCalls, 0, "body shown inline, overlay stays open"); - const bodyLines = stripAnsi(overlay.render(120).join("\n")); - assert.match(bodyLines, /body line/); - assert.match(bodyLines, /alpha/); - - // Second keypress closes the overlay - overlay.handleInput("\r"); - assert.equal(doneCalls, 1); -}); - -test("/notebook overlay sorts entries consistently", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const notebookWrite = pi.tools.get("notebook_write"); - await notebookWrite.execute("1", { name: "zeta", content: "last" }, undefined, undefined, makeTUICtx()); - await notebookWrite.execute("2", { name: "alpha", content: "first" }, undefined, undefined, makeTUICtx()); - let overlay: any; - - await pi.commands.get("notebook")!.handler("", { - hasUI: true, - ui: { - theme, - custom: async (build: any) => { - overlay = build({ requestRender: () => {} }, theme, {}, () => {}); - }, - notify: () => {}, - }, - }); - - const lines = stripAnsi(overlay.render(120).join("\n")); - assert.ok(lines.indexOf("alpha") < lines.indexOf("zeta"), lines); -}); - -test("saveNotebookPage serializes concurrent writes and preserves completion order", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - const firstGate = createDeferred(); - const order: string[] = []; - - const first = saveNotebookPage(pi as any, state, "entry-a", "first", async () => { - order.push("first:start"); - await firstGate.promise; - order.push("first:end"); - }); - const second = saveNotebookPage(pi as any, state, "entry-a", "second", async () => { - order.push("second:start"); - }); - - await Promise.resolve(); - assert.deepEqual(order, ["first:start"]); - firstGate.resolve(); - await Promise.all([first, second]); - - assert.deepEqual(order, ["first:start", "first:end", "second:start"]); - assert.equal(state.notebookPages.get("entry-a"), "second"); - assert.deepEqual(pi.appendedEntries.map((entry) => entry.data.content), ["first", "second"]); - resetNotebookWriteLock(); -}); - -test("saveNotebookPage rejects true reentrancy explicitly", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - - await assert.rejects( - () => saveNotebookPage(pi as any, state, "outer", "outer", async () => { - await saveNotebookPage(pi as any, state, "inner", "inner"); - }), - /not reentrant/i, - ); - assert.equal(state.notebookPages.size, 0); - resetNotebookWriteLock(); -}); - -test("saveNotebookPage releases the lock when assertWritable throws", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - - await assert.rejects( - () => saveNotebookPage(pi as any, state, "broken", "value", async () => { - throw new Error("blocked"); - }), - /blocked/, - ); - await assert.doesNotReject(() => saveNotebookPage(pi as any, state, "fresh", "value")); - assert.equal(state.notebookPages.get("fresh"), "value"); - resetNotebookWriteLock(); -}); - -test("resetNotebookWriteLock clears abandoned lock state for later writes", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - const gate = createDeferred(); - void saveNotebookPage(pi as any, state, "stuck", "value", async () => { - await gate.promise; - }); - await Promise.resolve(); - resetNotebookWriteLock(); - - await assert.doesNotReject(() => saveNotebookPage(pi as any, state, "fresh", "value")); - assert.equal(state.notebookPages.get("fresh"), "value"); - gate.resolve(); - resetNotebookWriteLock(); -}); - - -test("saveNotebookPage truncates oversized content before persisting", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - const content = "first line\n" + "detail\n".repeat(3000); - - const result = await saveNotebookPage(pi as any, state, "large-page", content); - const persisted = pi.appendedEntries[0].data.content; - - assert.ok(persisted.length < content.length, "oversized notebook content should be truncated"); - assert.equal(state.notebookPages.get("large-page"), persisted); - assert.equal(result.preview, "first line"); - assert.match(persisted, /^first line/m); - resetNotebookWriteLock(); -}); - - -test("resetState clears epoch and the next notebook write starts a fresh generation", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - const originalNow = Date.now; - - try { - Date.now = () => 1000; - await saveNotebookPage(pi as any, state, "entry-a", "first"); - await saveNotebookPage(pi as any, state, "entry-b", "second"); - assert.equal(state.epoch, 1000); - assert.equal(pi.appendedEntries[0].data.epoch, 1000); - assert.equal(pi.appendedEntries[1].data.epoch, 1000); - - resetState(state); - assert.equal(state.epoch, 0); - - Date.now = () => 2000; - await saveNotebookPage(pi as any, state, "entry-c", "third"); - assert.equal(state.epoch, 2000); - assert.equal(pi.appendedEntries[2].data.epoch, 2000); - } finally { - Date.now = originalNow; - resetNotebookWriteLock(); - } -}); - -test("nested spawn invalidate rebuilds from the attached session transcript", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "before" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const firstRender = component.render(120); - assert.ok(firstRender.some((l: string) => l.includes("before"))); - - session.messages[0].content[0].text = "after"; - component.invalidate(); - - const secondRender = component.render(120); - assert.notEqual(firstRender, secondRender); - assert.ok(secondRender.some((l: string) => l.includes("after"))); - assert.equal(secondRender.some((l: string) => l.includes("before")), false); -}); - -test("nested spawn attachSession rebuilds after appended session messages", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - state.childSessions.set("tool-call-1", createSession([ - { role: "assistant", content: [{ type: "text", text: "before" }] }, - ])); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const firstRender = component.render(120); - assert.ok(firstRender.some((l: string) => l.includes("before"))); - - state.childSessions.set("tool-call-1", createSession([ - { role: "assistant", content: [{ type: "text", text: "before" }] }, - { role: "assistant", content: [{ type: "text", text: "after" }] }, - ])); - const sameComponent = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ lastComponent: component }), - ) as any; - - const secondRender = sameComponent.render(120); - assert.notEqual(firstRender, secondRender); - assert.ok(secondRender.some((l: string) => l.includes("after"))); -}); - -test("nested spawn attachSession rebuilds after replacing session transcript structure", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - state.childSessions.set("tool-call-1", createSession([ - { role: "assistant", content: [{ type: "text", text: "before" }] }, - ])); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const firstRender = component.render(120); - assert.ok(firstRender.some((l: string) => l.includes("before"))); - - state.childSessions.set("tool-call-1", createSession([ - { role: "user", content: [{ type: "text", text: "new task" }] }, - { role: "assistant", content: [{ type: "text", text: "replacement" }] }, - ])); - const sameComponent = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ lastComponent: component }), - ) as any; - - const secondRender = sameComponent.render(120); - assert.notEqual(firstRender, secondRender); - assert.ok(secondRender.some((l: string) => l.includes("replacement"))); - assert.equal(secondRender.some((l: string) => l.includes("before")), false); -}); - -test("nested spawn rebuildFromSession quietly tolerates missing tool definitions", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = { - messages: [{ - role: "assistant", - content: [{ type: "toolCall", name: "bash", id: "tc-1", arguments: { command: "ls" } }], - stopReason: "error", - errorMessage: "boom", - }], - subscribe: () => () => {}, - getToolDefinition: () => { throw new Error("missing tool definition"); }, - sessionManager: { getCwd: () => process.cwd() }, - abort: async () => {}, - } as any; - state.childSessions.set("tool-call-1", session); - - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => warnings.push(args); - - try { - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false, outcome: "error" } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("⚠ m • low"))); - assert.ok(lines.some((l: string) => l.includes("error"))); - assert.equal(state.childSessions.has("tool-call-1"), false); - assert.deepEqual(warnings, []); - } finally { - console.warn = originalWarn; - } -}); - -test("nested spawn attachSession recovers from subscribe throwing", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - - // Session whose subscribe() throws - const throwingSession = { - messages: [{ role: "assistant", content: [{ type: "text", text: "hello" }] }], - subscribe: () => { throw new Error("subscribe failed"); }, - getToolDefinition: () => undefined, - sessionManager: { getCwd: () => process.cwd() }, - abort: async () => {}, - } as any; - state.childSessions.set("tool-call-1", throwingSession); - - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => warnings.push(args); - - try { - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // Should not crash, session attached, ownership transferred - assert.equal(state.childSessions.has("tool-call-1"), false); - assert.equal(warnings.length, 1); - assert.match(String(warnings[0][0]), /Failed to subscribe/); - - // Should still render from session messages despite subscribe failure - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("hello"))); - } finally { - console.warn = originalWarn; - } -}); - -test("nested spawn rapid events collapse to last state", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // Start a tool execution - emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); - - // Rapid burst of updates without rendering between them - emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file1" }] } }); - emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file2" }] } }); - emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file3" }] } }); - - // Single render should reflect last state - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("file3"))); - - // End the tool and verify final state - emit({ type: "tool_execution_end", toolCallId: "tc-1", result: { content: [{ type: "text", text: "done" }] }, isError: false }); - - const finalLines = component.render(120); - assert.ok(finalLines.some((l: string) => l.includes("✓"))); -}); - -// Narrow test: verifies the pendingToolCallCreations accumulation layer keeps the -// last streamed args, overwriting on each message_update. The monkey-patch on -// createToolComponent captures args before component creation. If the private -// method is renamed, update the spy target. -test("nested spawn uses the latest streamed tool-call args before first frame flush", () => { - resetSpawnFrameScheduler(); - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: true }, - theme, - createRenderContext(), - ) as any; - let createdArgs: any; - component.createToolComponent = (_toolName: string, _toolCallId: string, args: any) => { - createdArgs = args; - return undefined; - }; - - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - emit({ - type: "message_update", - message: { role: "assistant", content: [{ type: "toolCall", id: "tc-1", name: "inspect", arguments: { value: "old" } }] }, - }); - emit({ - type: "message_update", - message: { role: "assistant", content: [{ type: "toolCall", id: "tc-1", name: "inspect", arguments: { value: "new" } }] }, - }); - flushSpawnFrameScheduler(); - - assert.deepEqual(createdArgs, { value: "new" }); -}); - -test("nested spawn coalesces same-turn child events into one parent invalidate", async () => { - resetSpawnFrameScheduler(); - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "file1" }] } }); - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "file2" }] } }); - - assert.equal(invalidateCalls, 0, "child events do not invalidate synchronously"); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "same-turn events coalesce into one invalidate"); - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("file2"))); -}); - -test("nested spawn ignores child renderer invalidations during parent rebuild", async () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session } = createSubscribableSession([]); - (session as any).getToolDefinition = (toolName: string) => toolName === "reentrant" - ? { - name: "reentrant", - renderCall(_args: any, _theme: Theme, context: any) { - if (!context.state.didInvalidate) { - context.state.didInvalidate = true; - context.invalidate(); - } - return new Text("reentrant", 0, 0); - }, - } - : undefined; - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 0, "initial empty attach does not invalidate"); - - (session as any).messages = [{ - role: "assistant", - content: [{ type: "toolCall", id: "tc-1", name: "reentrant", arguments: {} }], - }]; - component.invalidate(); - flushSpawnFrameScheduler(); - - assert.equal(invalidateCalls, 0, "child renderer invalidate requests stay inside spawn rebuild"); -}); - -test("nested spawn shared scheduler calls each distinct invalidate once per frame", async () => { - resetSpawnFrameScheduler(); - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const first = createSubscribableSession([]); - const second = createSubscribableSession([]); - state.childSessions.set("tool-call-1", first.session); - state.liveChildSessions.set("tool-call-1", first.session); - state.childSessions.set("tool-call-2", second.session); - state.liveChildSessions.set("tool-call-2", second.session); - let firstInvalidates = 0; - let secondInvalidates = 0; - - const firstComponent = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ toolCallId: "tool-call-1", invalidate: () => { firstInvalidates++; } }), - ) as any; - const secondComponent = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ toolCallId: "tool-call-2", invalidate: () => { secondInvalidates++; } }), - ) as any; - - first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - first.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "first latest" }] } }); - second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "second latest" }] } }); - - assert.equal(firstInvalidates, 0, "shared scheduler defers parent invalidate"); - assert.equal(secondInvalidates, 0, "shared scheduler defers parent invalidate"); - flushSpawnFrameScheduler(); - assert.equal(firstInvalidates, 1); - assert.equal(secondInvalidates, 1); - assert.ok(firstComponent.render(120).some((l: string) => l.includes("first latest"))); - assert.ok(secondComponent.render(120).some((l: string) => l.includes("second latest"))); -}); - -test("nested spawn shared scheduler still coalesces duplicate invalidate callbacks", async () => { - resetSpawnFrameScheduler(); - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const first = createSubscribableSession([]); - const second = createSubscribableSession([]); - state.childSessions.set("tool-call-1", first.session); - state.liveChildSessions.set("tool-call-1", first.session); - state.childSessions.set("tool-call-2", second.session); - state.liveChildSessions.set("tool-call-2", second.session); - let invalidateCalls = 0; - const invalidate = () => { invalidateCalls++; }; - - childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ toolCallId: "tool-call-1", invalidate }), - ); - childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ toolCallId: "tool-call-2", invalidate }), - ); - - first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "identical callbacks still coalesce"); -}); - -test("nested spawn renders state changes across frame boundaries", async () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // First batch: message_start sets thinking state, flush triggers render - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - const firstLines = component.render(120); - assert.ok(firstLines.some((l: string) => l.includes("thinking"))); - - // Second batch: message_update with new text, flush triggers new render - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "batch 2" }] } }); - flushSpawnFrameScheduler(); - const secondLines = component.render(120); - assert.ok(secondLines.some((l: string) => l.includes("batch 2"))); -}); - -test("nested spawn dispose cancels pending and further invalidates after cleanup", async () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - assert.equal(invalidateCalls, 0, "event does not invalidate synchronously"); - - component.dispose(); - flushSpawnFrameScheduler(); - - // After dispose, emitting more events does not call invalidate - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "after" }] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 0, "dispose cancels pending and future invalidates"); - - // Render still works after dispose without crashing - const lines = component.render(120); - assert.ok(lines.length > 0, "render after dispose should not crash"); -}); - -test("nested spawn reattach resets render guard for the new session", async () => { - resetSpawnFrameScheduler(); - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const first = createSubscribableSession([]); - state.childSessions.set("tool-call-1", first.session); - state.liveChildSessions.set("tool-call-1", first.session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - - first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "first session event triggers invalidate after scheduler flush"); - - // Reattach resets the render guard - const second = createSubscribableSession([{ role: "assistant", content: [{ type: "text", text: "replacement" }] }]); - state.childSessions.set("tool-call-1", second.session); - state.liveChildSessions.set("tool-call-1", second.session); - const sameComponent = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ lastComponent: component, invalidate: () => { invalidateCalls++; } }), - ) as any; - - second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "replacement 2" }] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 2, "second session event triggers another invalidate after reattach"); - const lines = sameComponent.render(120); - assert.ok(lines.some((l: string) => l.includes("replacement 2"))); -}); - -test("nested spawn recovers batching state after event handler error", async () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => warnings.push(args); - try { - // Bad event triggers an error in handleMessageStart (null message) - // catch block must call resetRenderBatching() so the flag resets - emit({ type: "message_start", message: null } as any); - - // Good event after error — should still schedule and render - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("thinking")), - "error recovery should allow subsequent events to render"); - assert.equal(warnings.length, 1); - assert.match(String(warnings[0][0]), /Event handler error/); - } finally { - console.warn = originalWarn; - } -}); - -test("nested spawn processes stale-state events without invalidating the parent", async () => { - resetSpawnFrameScheduler(); - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - // Emit a message_start while the session is still fresh — triggers a render after flush - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "fresh-session event triggers invalidate"); - - // Now mark the session stale - state.liveChildSessions.delete("tool-call-1"); - - // Subsequent events are dropped by handleEvent's isStaleSession check - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "stale" }] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "stale-session events do not invalidate"); - - // The optimistic event state was applied (message_start set thinking), - // but stale-session updates are dropped — the component shows the last - // known state before staleness, not a rolled-back version. - const after = component.render(120); - assert.ok(after.some((l: string) => l.includes("thinking")), - "optimistic event state from when session was still fresh is visible"); - assert.ok(!after.some((l: string) => l.includes("stale")), - "stale-session events are dropped"); -}); - -test("nested spawn cancels a queued parent invalidate when the session becomes stale before flush", async () => { - resetSpawnFrameScheduler(); - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - state.liveChildSessions.delete("tool-call-1"); - flushSpawnFrameScheduler(); - - assert.equal(invalidateCalls, 0, "stale-before-flush sessions cancel queued parent invalidates"); - assert.deepEqual(component.render(120), before, "stale-before-flush sessions roll back optimistic event state"); -}); - -test("nested spawn dispose then reattach streams new session events", async () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const first = createSubscribableSession([]); - state.childSessions.set("tool-call-1", first.session); - state.liveChildSessions.set("tool-call-1", first.session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "first" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - component.dispose(); - - // Attach a second session to the same toolCallId after dispose - const second = createSubscribableSession([ - { role: "assistant", content: [{ type: "text", text: "second" }] }, - ]); - state.childSessions.set("tool-call-1", second.session); - state.liveChildSessions.set("tool-call-1", second.session); - const reattached = childSpawnTool.renderResult( - { content: [{ type: "text", text: "second" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ lastComponent: component }), - ) as any; - - second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "session B output" }] } }); - flushSpawnFrameScheduler(); - - const lines = reattached.render(120); - assert.ok(lines.some((l: string) => l.includes("session B output")), - "reattached component should render events from the new session"); - assert.equal(lines.some((l: string) => l.includes("first")), false, - "reattached component should not show stale content from disposed session"); -}); - -test("nested spawn drops late events after live registry deletion", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - state.liveChildSessions.delete("tool-call-1"); - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - - const after = component.render(120); - assert.equal(invalidateCalls, 0, "completed-session deletion should stop rerenders from late events"); - assert.deepEqual(after, before, "completed-session deletion should freeze the rendered state"); -}); - -test("nested spawn drops events after resetState bumps child epoch", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - resetState(state); - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - - const after = component.render(120); - assert.equal(invalidateCalls, 0, "stale events should not request rerender after reset"); - assert.deepEqual(after, before, "stale events should not change rendered state after reset"); -}); - -test("nested spawn drops events when session is replaced in live state", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - const replacementSession = createSubscribableSession([]).session; - state.liveChildSessions.set("tool-call-1", replacementSession); - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - - const after = component.render(120); - assert.equal(invalidateCalls, 0, "replaced sessions should not request rerender"); - assert.deepEqual(after, before, "replaced sessions should not change rendered state"); -}); - -test("nested spawn completed-session deletion stays stale even if the toolCallId is later reused", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - state.liveChildSessions.delete("tool-call-1"); - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - const afterDeletion = component.render(120); - assert.equal(invalidateCalls, 0, "completed-session deletion should immediately stale the old session"); - assert.deepEqual(afterDeletion, before, "completed-session deletion should freeze the rendered state before reuse"); - - state.liveChildSessions.set("tool-call-1", createSubscribableSession([]).session); - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "should be dropped" }] } }); - const afterReuse = component.render(120); - assert.equal(invalidateCalls, 0, "toolCallId reuse should not revive a completed stale session"); - assert.deepEqual(afterReuse, before, "toolCallId reuse should keep the old rendered state frozen"); - assert.ok(afterReuse.every((l: string) => !l.includes("should be dropped")), "toolCallId reuse should not admit stale text updates"); -}); - -test("concurrent spawn executions produce independent results", async () => { - const pi = new MockPi(); - const state = createState(); - - let resolveA!: () => void; - let resolveB!: () => void; - let markStartedA!: () => void; - let markStartedB!: () => void; - const gateA = new Promise((resolve) => { resolveA = resolve; }); - const gateB = new Promise((resolve) => { resolveB = resolve; }); - const startedA = new Promise((resolve) => { markStartedA = resolve; }); - const startedB = new Promise((resolve) => { markStartedB = resolve; }); - const started: string[] = []; - const outputs = new Map([ - ["task A", "result-alpha"], - ["task B", "result-beta"], - ]); - const sharedFactory = async () => { - const session = { - messages: [] as any[], - prompt: async (prompt: string) => { - const task = /## Task\n\n([\s\S]*?)\n\nWhen complete/.exec(prompt)?.[1] ?? ""; - started.push(task); - if (task === "task A") { - markStartedA(); - await gateA; - } - if (task === "task B") { - markStartedB(); - await gateB; - } - session.messages = [{ role: "assistant", content: [{ type: "text", text: outputs.get(task) ?? task }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, sharedFactory as any); - const spawnTool = pi.tools.get("spawn"); - - const resultP1 = spawnTool.execute( - "spawn-A", { prompt: "task A" }, undefined, undefined, - { model: { id: "mock" }, cwd: "/tmp" }, - ); - const resultP2 = spawnTool.execute( - "spawn-B", { prompt: "task B" }, undefined, undefined, - { model: { id: "mock" }, cwd: "/tmp" }, - ); - - await Promise.all([startedA, startedB]); - assert.deepEqual(started.sort(), ["task A", "task B"]); - resolveA(); - resolveB(); - - const [r1, r2] = await Promise.all([resultP1, resultP2]); - - assert.equal(r1.content[0].text, "result-alpha"); - assert.equal(r2.content[0].text, "result-beta"); - assert.equal(state.childSessions.has("spawn-A"), false); - assert.equal(state.childSessions.has("spawn-B"), false); -}); - -test("nested spawn render cache preserves stable output for identical params", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const first = component.render(120); - const second = component.render(120); - assert.deepEqual(second, first); - - const wide = component.render(200); - assert.ok(Array.isArray(wide)); - assert.ok(wide.some((l: string) => l.includes("hello") || l.includes("m • low"))); -}); - -test("notebook tool definitions include prompt hints when withPromptHints is true", () => { - const pi = new MockPi(); - const state = createState(); - const tools = createNotebookToolDefinitions(pi as any, state, { withPromptHints: true }); - - for (const tool of tools) { - assert.ok(typeof tool.promptSnippet === "string", `${tool.name} should have promptSnippet when withPromptHints=true`); - assert.ok(Array.isArray(tool.promptGuidelines), `${tool.name} should have promptGuidelines when withPromptHints=true`); - } - const notebookWrite = tools.find(t => t.name === "notebook_write")!; - const notebookRead = tools.find(t => t.name === "notebook_read")!; - const notebookIndex = tools.find(t => t.name === "notebook_index")!; - - // Structural invariants: all guidelines exist and are non-trivial - for (const tool of tools) { - assert.ok(tool.promptGuidelines!.length >= 2, `${tool.name} should have at least 2 promptGuidelines`); - assert.ok(tool.promptGuidelines!.every((g: string) => g.length > 10), `${tool.name} each guideline should be non-trivial`); - } - - // Conceptual: notebook_write is future-context oriented - const writeGuidelines = notebookWrite.promptGuidelines!.join(" "); - assert.match(writeGuidelines, /subject-oriented pages/i); - assert.match(writeGuidelines, /fresh context/i); - assert.match(writeGuidelines, /belongs in handoff/i); - - // Conceptual: descriptions mention the notebook-page metaphor - assert.match(notebookWrite.description, /page|future contexts/i); - assert.match(notebookRead.description, /notebook page|page/i); - assert.match(notebookIndex.description, /notebook index|index/i); -}); - -test("topic helpers manage the active notebook topic lifecycle", () => { - const state = createState(); - const first = setActiveNotebookTopic(state, "OAuth", "agent"); - assert.deepEqual(first, { - changed: true, - previous: null, - current: "oauth", - boundaryHint: null, - }); - const second = setActiveNotebookTopic(state, "Billing", "human"); - assert.equal(second.boundaryHint?.from, "oauth"); - assert.equal(second.boundaryHint?.to, "billing"); - clearActiveNotebookTopic(state); - assert.equal(state.activeNotebookTopic, null); - assert.equal(state.activeNotebookTopicSource, null); - assert.equal(state.pendingTopicBoundaryHint, null); -}); - -test("notebook_topic_set establishes a fresh topic, is idempotent, and refuses overrides", async () => { - const pi = new MockPi(); - const state = createState(); - registerNotebookTopicTool(pi as any, state); - - const tool = pi.tools.get("notebook_topic_set"); - const first = await tool.execute("1", { topic: "OAuth" }); - assert.equal(first.details.topic, "oauth"); - assert.equal(state.activeNotebookTopic, "oauth"); - assert.equal(state.activeNotebookTopicSource, "agent"); - - const second = await tool.execute("2", { topic: "oauth" }); - assert.equal(second.details.changed, false); - assert.equal(second.details.source, "agent"); - assert.match(second.content[0].text, /already set to "oauth"/i); - - await assert.rejects(() => tool.execute("3", { topic: "billing" }), /already exists/); -}); - - -test("notebook_topic_set preserves human authority, stays idempotent for equal topics, and rejects empty normalized topics", async () => { - const pi = new MockPi(); - const state = createState(); - registerNotebookTopicTool(pi as any, state); - const tool = pi.tools.get("notebook_topic_set"); - - setActiveNotebookTopic(state, "oauth", "human"); - const same = await tool.execute("1", { topic: "OAuth" }); - assert.equal(same.details.changed, false); - assert.equal(same.details.source, "human"); - assert.match(same.content[0].text, /already set to "oauth"/i); - await assert.rejects( - () => tool.execute("2", { topic: "billing" }), - /human-set notebook topic is authoritative/i, - ); - - const freshPi = new MockPi(); - const freshState = createState(); - registerNotebookTopicTool(freshPi as any, freshState); - const freshTool = freshPi.tools.get("notebook_topic_set"); - await assert.rejects( - () => freshTool.execute("3", { topic: "@@@" }), - /notebook topic cannot be empty/i, - ); -}); - -test("buildNudge no longer emits the old percent-only handoff text", () => { - const old = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null }, 46); - assert.doesNotMatch(old, /One context, one job\.|If you're mid-job and still clear|consider a handoff and draft a clear brief/i); - assert.match(old, /Active notebook topic: oauth/); - assert.match(old, /prefer spawn/i); -}); - - -test("CONTEXT_PRIMER states the notebook, topic, and handoff contracts", () => { - assert.doesNotMatch(CONTEXT_PRIMER, /ledger/i, - "CONTEXT_PRIMER should contain zero stale ledger references after the rename"); - - const notebookParts = CONTEXT_PRIMER.split("### Notebook"); - const topicParts = CONTEXT_PRIMER.split("### Active notebook topic"); - const handoffParts = CONTEXT_PRIMER.split("### Handoff"); - const rulesParts = CONTEXT_PRIMER.split("### Rules"); - assert.equal(notebookParts.length, 2); - assert.equal(topicParts.length, 2); - assert.equal(handoffParts.length, 2); - assert.equal(rulesParts.length, 2); - - const notebookSection = notebookParts[1].split("### Active notebook topic")[0]; - const topicSection = topicParts[1].split("### Handoff")[0]; - const handoffSection = handoffParts[1].split("### Rules")[0]; - const rulesSection = rulesParts[1]; - - assert.match(notebookSection, /notebook_index/); - assert.match(notebookSection, /notebook_read/); - assert.match(notebookSection, /future contexts/i); - assert.match(topicSection, /semantic frame/i); - assert.match(topicSection, /prefer spawn/i); - assert.match(topicSection, /prefer handoff/i); - assert.match(handoffSection, /handoff/i); - assert.match(handoffSection, /notebook/i); - assert.match(rulesSection, /one subject, thread, or subsystem/i); -}); - - -test("before_agent_start injects notebook contracts plus live topic and page data", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); - const notebookWrite = pi.tools.get("notebook_write"); - await notebookWrite.execute("1", { name: "alpha", content: "first line\nsecond line" }, undefined, undefined, makeTUICtx()); - - const [handler] = pi.handlers.get("before_agent_start")!; - const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); - - assert.match(result.systemPrompt, /Base system prompt\./); - assert.match(result.systemPrompt, /## Context management/); - assert.match(result.systemPrompt, /## Active Notebook Topic/); - assert.match(result.systemPrompt, /Current topic: `oauth`/); - assert.match(result.systemPrompt, /## Active Notebook Pages/); - assert.match(result.systemPrompt, /notebook_read/); - assert.match(result.systemPrompt, /Reference pages by name/i); - assert.match(result.systemPrompt, /alpha: first line/); -}); - - -test("before_agent_start injects no-topic guidance when the topic is unset", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("before_agent_start")!; - const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); - - assert.match(result.systemPrompt, /## Active Notebook Topic/); - assert.match(result.systemPrompt, /No active notebook topic is set\./); - assert.match(result.systemPrompt, /notebook_topic_set/); -}); - -test("notebook tool definitions omit prompt hints by default", () => { - const pi = new MockPi(); - const state = createState(); - const tools = createNotebookToolDefinitions(pi as any, state); - - for (const tool of tools) { - assert.equal(tool.promptSnippet, undefined, `${tool.name} should not have promptSnippet by default`); - assert.equal(tool.promptGuidelines, undefined, `${tool.name} should not have promptGuidelines by default`); - } -}); - -test("spawn tool definitions include prompt hints when registered", () => { - const pi = new MockPi(); - const state = createState(); - registerSpawnTool(pi as any, state); - - const spawnTool = pi.tools.get("spawn")!; - assert.ok(typeof spawnTool.promptSnippet === "string", "spawn should have promptSnippet"); - assert.ok(spawnTool.promptSnippet!.length > 10, "spawn promptSnippet should be non-trivial"); - assert.ok(Array.isArray(spawnTool.promptGuidelines), "spawn should have promptGuidelines"); - assert.ok(spawnTool.promptGuidelines!.length > 0, "spawn promptGuidelines should be non-empty"); - for (const g of spawnTool.promptGuidelines!) { - assert.ok(g.length > 10, "each spawn guideline should be non-trivial"); - } -}); - -test("executeSpawn detects stale session before session creation", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - let resolveFactory!: (value: any) => void; - const factoryReady = new Promise((resolve) => { - resolveFactory = resolve; - }); - let factoryCalled = false; - let abortCalls = 0; - - const executePromise = executeSpawn( - "spawn-1", - pi as any, - { model: { id: "mock-model" }, cwd: "/tmp" } as any, - state, - { prompt: "Do the task" }, - undefined, - undefined, - "medium", - async () => { - factoryCalled = true; - await factoryReady; - return { - session: { - messages: [] as any[], - prompt: async () => {}, - abort: async () => { abortCalls++; }, - getSessionStats: () => undefined, - } as any, - }; - }, - ); - - // Reset state while executeSpawn is awaiting the factory - resetState(state); - // Now allow the factory to resolve — session should be immediately stale - resolveFactory({}); - - await assert.rejects( - () => executePromise, - /invalidated by reset/i, - ); - assert.equal(factoryCalled, true); - assert.equal(abortCalls, 1); - assert.equal(state.childSessions.size, 0); - assert.equal(state.liveChildSessions.size, 0); -}); - -test("executeSpawn aborts stale child when resetState fires during prompt", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - let rejectPrompt!: (err: Error) => void; - let resolvePromptStarted!: () => void; - const promptStartedPromise = new Promise((r) => { resolvePromptStarted = r; }); - let abortCalls = 0; - - const executePromise = executeSpawn( - "spawn-1", - pi as any, - { model: { id: "mock-model" }, cwd: "/tmp" } as any, - state, - { prompt: "Do the task" }, - undefined, - undefined, - "medium", - async () => ({ - session: { - messages: [] as any[], - prompt: async () => { - resolvePromptStarted(); - await new Promise((_resolve, reject) => { - rejectPrompt = reject; - }); - }, - abort: async () => { - abortCalls++; - rejectPrompt?.(new Error("aborted")); - }, - getSessionStats: () => undefined, - } as any, - }), - ); - - // Wait for session to be created and prompt to start - await promptStartedPromise; - // Reset state triggers abortAndClearChildSessions which calls session.abort() - // abort() rejects the pending prompt, which causes the stale check to fire - resetState(state); - - await assert.rejects( - () => executePromise, - /invalidated by reset/i, - ); - // abort is called once by clearChildSession (identity match via liveChildSessions) - assert.equal(abortCalls >= 1, true); - assert.equal(state.childSessions.size, 0); - assert.equal(state.liveChildSessions.size, 0); -}); - -test("handleEvent gracefully degrades with null message events", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // asToolResult is exercised indirectly through tool_execution_update - // with null partialResult — the runtime guard should handle it without crashing - emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); - emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: null }); - emit({ type: "tool_execution_end", toolCallId: "tc-1", result: null, isError: false }); - - // No crash = asToolResult guard works - const lines = component.render(120); - assert.ok(Array.isArray(lines)); -}); - -test("truncateText respects line limit before byte limit", async () => { - const pi = new MockPi(); - pi.setActiveTools(["read", "bash", "spawn"]); - const state = createState(); - - // Generate text with > 2000 lines to trigger line truncation - const text = Array.from({ length: 2500 }, (_, i) => `Line ${i}`).join("\n"); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); - - const result = await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Generate lots of lines" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - assert.equal(result.details.truncated, true); - const textLines = result.content[0].text.split("\n"); - assert.ok(textLines[0].startsWith("Line 0"), `expected first line, got: ${textLines[0]}`); - assert.ok(result.content[0].text.includes("[Result truncated")); -}); - -test("nested spawn setExpanded and setShowImages no-op when value matches", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // Calling setExpanded with same value should not throw or crash - component.setExpanded(false); - component.setExpanded(true); - component.setShowImages(true); - component.setShowImages(false); - - // Component still renders - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("hello"))); -}); - -test("abortAndClearChildSessions deduplicates sessions across both maps", () => { - const state = createState(); - let abortCalls = 0; - const mockSession = { - messages: [], - abort: async () => { abortCalls++; }, - } as any; - - // Put the same session object in both maps under the same key - state.childSessions.set("tc-1", mockSession); - state.liveChildSessions.set("tc-1", mockSession); - - resetState(state); - - // Dedup via the `seen` map ensures abort is called exactly once - assert.equal(abortCalls, 1); - assert.equal(state.childSessions.size, 0); - assert.equal(state.liveChildSessions.size, 0); -}); - -test("renderSpawnResult handles result with no details field", () => { - const state = createState(); - const result = renderSpawnResult( - { content: [{ type: "text", text: "hello" }] }, - false, - theme, - { toolCallId: "tc-1", invalidate: () => {}, showImages: false }, - state, - ); - // Should return a Text component that renders without crashing - assert.ok(result, "renderSpawnResult should return a component"); - const lines = (result as any).render(120); - assert.ok(Array.isArray(lines), "render should return an array of lines"); - assert.ok(lines.some((l: string) => l.includes("hello")), `expected 'hello' in output, got: ${lines.join("\n")}`); -}); - -test("registerSpawnTool registers a tool with correct name and metadata", () => { - const pi = new MockPi(); - const state = createState(); - registerSpawnTool(pi as any, state); - - const tool = pi.tools.get("spawn"); - assert.ok(tool, "spawn tool should be registered"); - assert.equal(tool.name, "spawn"); - assert.equal(tool.label, "Spawn"); - assert.equal(typeof tool.description, "string"); - assert.match(tool.description, /active registered tools executable in the child session/); - assert.match(tool.description, /shared notebook tools/); - assert.match(tool.description, /cannot spawn or handoff/); - assert.doesNotMatch(tool.description, /supported built-in tools/); - assert.equal(typeof tool.execute, "function"); - assert.equal(typeof tool.renderCall, "function"); - assert.equal(typeof tool.renderResult, "function"); - assert.equal(tool.renderShell, "self"); - // parameters are a TypeBox schema object — just verify it exists - assert.ok(tool.parameters, "should have parameters"); - assert.equal(tool.executionMode, undefined, "spawn should not be sequential"); -}); - -test("spawn docs document active registered inheritance", async () => { - const readme = await readFile("README.md", "utf8"); - const changelog = await readFile("CHANGELOG.md", "utf8"); - const spawnSection = /### Spawn — Isolate Noise[\s\S]*?### Notebook/.exec(readme)?.[0] ?? ""; - const unreleased = /## \[Unreleased\][\s\S]*?## \[0\.3\.0\]/.exec(changelog)?.[0] ?? ""; - - assert.match(spawnSection, /active registered tools executable in the child session/); - assert.match(spawnSection, /MCP\/extension tools such as ChunkHound/); - assert.match(spawnSection, /[Cc]hild-local notebook tools/); - assert.match(spawnSection, /cannot spawn grandchildren or handoff/); - assert.doesNotMatch(spawnSection, /built-in tools only/); - assert.match(unreleased, /active registered parent tools/); - assert.match(unreleased, /spawn and handoff/); - assert.match(unreleased, /notebook tools/); -}); diff --git a/notebook/rehydration.ts b/notebook/rehydration.ts index 08e19e2..91e3cf7 100644 --- a/notebook/rehydration.ts +++ b/notebook/rehydration.ts @@ -7,7 +7,7 @@ * notebook_read / notebook_index are active. */ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { CustomEntry, ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; // ── Types ───────────────────────────────────────────────────────────── @@ -45,12 +45,12 @@ export function registerNotebookRehydration( if ( entry.type !== "custom" || - !ENTRY_TYPES.has((entry as Record).customType as string) + !ENTRY_TYPES.has((entry as CustomEntry).customType) ) { continue; } - const data = (entry as Record).data as NotebookEntryData | undefined; + const data = (entry as CustomEntry).data; if (!data?.name || typeof data.content !== "string") continue; // Skip if we already have a newer version of this name diff --git a/notebook/store.ts b/notebook/store.ts index f9b048b..04d7e3b 100644 --- a/notebook/store.ts +++ b/notebook/store.ts @@ -10,39 +10,37 @@ import { DEFAULT_MAX_LINES, truncateHead, } from "@earendil-works/pi-coding-agent"; -import { AsyncLocalStorage } from "node:async_hooks"; import type { AgenticodingState } from "../state.js"; - -/** - * Module-level write lock state. - * - * Concurrent callers serialize by chaining on the prior promise. Reentrancy is - * tracked per async call chain so a nested saveNotebookPage fails explicitly - * without rejecting unrelated concurrent writers that happen to overlap. - */ -let writeLock: Promise = Promise.resolve(); -const writeContext = new AsyncLocalStorage(); +import { createWriteLock, __setSingletons, getSingletons } from "../runtime-singletons.js"; /** Reset write lock state. Only for test cleanup after concurrent runs. */ export function resetNotebookWriteLock(): void { - writeLock = Promise.resolve(); + __setSingletons( + { ...getSingletons(), writeLock: createWriteLock() }, + { forceWriteLock: true }, + ); } async function withWriteLock(fn: () => Promise): Promise { - if (writeContext.getStore()) { + const s = getSingletons(); + const lock = s.writeLock; + if (s.writeContext.getStore()) { throw new Error( "Notebook write lock is not reentrant — saveNotebookPage called from within its own critical section.", ); } let release: () => void; - const prev = writeLock; - writeLock = new Promise((resolve) => { + const prev = lock.tail; + const next = new Promise((resolve) => { release = resolve; }); + lock.pending += 1; + lock.tail = next; await prev; try { - return await writeContext.run(true, fn); + return await s.writeContext.run(true, fn); } finally { + lock.pending -= 1; release!(); } } diff --git a/notebook/tools.ts b/notebook/tools.ts index 4f13c1d..a03bad2 100644 --- a/notebook/tools.ts +++ b/notebook/tools.ts @@ -8,11 +8,39 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; -import { Type } from "typebox"; +import { Type, type Static } from "typebox"; import type { AgenticodingState } from "../state.js"; import { updateIndicators } from "../tui.js"; import { formatPageList, formatPagePreview, getPageNames, saveNotebookPage } from "./store.js"; +// ── Parameter schemas ──────────────────────────────────────────────── +// Extracted to const so type inference works through ToolDefinition. + +const notebookWriteParams = Type.Object({ + name: Type.String({ + description: + "Kebab-case notebook page identifier. Prefer stable subject-oriented names; using an existing name overwrites that page (refinement).", + }), + content: Type.String({ + description: + "Compact markdown for one notebook page. Capture only durable, high-value " + + "grounding for one subject or thread, such as facts, architecture, decisions, constraints, " + + "open questions, or expensive discoveries. Compact sections like Facts / Architecture / Decisions / Constraints / Open questions work well. Truncated at 50KB / 2000 lines.", + }), +}); + +const notebookReadParams = Type.Object({ + name: Type.String({ + description: "Notebook page name to retrieve.", + }), +}); + +const notebookIndexParams = Type.Object({}); + +type WriteArgs = Static; +type ReadArgs = Static; +type IndexArgs = Static; + // ── Factory ─────────────────────────────────────────────────────────── /** @@ -34,7 +62,7 @@ export function createNotebookToolDefinitions( } }; - const notebookWrite: ToolDefinition = { + const notebookWrite: ToolDefinition = { name: "notebook_write", label: "Notebook Write", description: @@ -55,19 +83,8 @@ export function createNotebookToolDefinitions( } : {}), executionMode: "sequential", - parameters: Type.Object({ - name: Type.String({ - description: - "Kebab-case notebook page identifier. Prefer stable subject-oriented names; using an existing name overwrites that page (refinement).", - }), - content: Type.String({ - description: - "Compact markdown for one notebook page. Capture only durable, high-value " + - "grounding for one subject or thread, such as facts, architecture, decisions, constraints, " + - "open questions, or expensive discoveries. Compact sections like Facts / Architecture / Decisions / Constraints / Open questions work well. Truncated at 50KB / 2000 lines.", - }), - }), - renderCall(args, theme, _context) { + parameters: notebookWriteParams, + renderCall(args: WriteArgs, theme, _context) { const preview = formatPagePreview(args.content).trim(); let text = theme.fg("toolTitle", theme.bold("notebook_write ")) + @@ -78,7 +95,7 @@ export function createNotebookToolDefinitions( return new Text(text, 0, 0); }, - renderResult(result, { expanded }, theme, context) { + renderResult(result, { expanded }, theme, context: { args: WriteArgs }) { const details = result.details as { entries: string[]; preview: string }; let text = theme.fg("success", "\u2713 Saved ") + theme.fg("accent", `"${context.args.name}"`); @@ -91,14 +108,14 @@ export function createNotebookToolDefinitions( return new Text(text, 0, 0); }, - async execute(_toolCallId, params, _signal, onUpdate, ctx) { + async execute(_toolCallId, params: WriteArgs, _signal, onUpdate, ctx) { assertFresh(); const saved = await saveNotebookPage(pi, state, params.name, params.content, assertFresh); updateIndicators(ctx, state); onUpdate?.({ content: [{ - type: "text", + type: "text" as const, text: `Saved "${params.name}"` + (saved.preview ? `: ${saved.preview}` : ""), }], details: { entries: saved.entries, preview: saved.preview }, @@ -106,7 +123,7 @@ export function createNotebookToolDefinitions( return { content: [ { - type: "text", + type: "text" as const, text: `Saved notebook page "${params.name}".` + (saved.preview ? `\n${saved.preview}` : "") + `\n\nNotebook Pages:\n${formatPageList(state) || "(empty)"}`, @@ -117,7 +134,7 @@ export function createNotebookToolDefinitions( }, }; - const notebookRead: ToolDefinition = { + const notebookRead: ToolDefinition = { name: "notebook_read", label: "Notebook Read", description: @@ -133,12 +150,8 @@ export function createNotebookToolDefinitions( ], } : {}), - parameters: Type.Object({ - name: Type.String({ - description: "Notebook page name to retrieve.", - }), - }), - renderResult(result, { expanded }, theme, context) { + parameters: notebookReadParams, + renderResult(result, { expanded }, theme, context: { args: ReadArgs }) { const details = result.details as { entries: string[]; found: boolean; body?: string }; if (!details.found) { return new Text( @@ -154,7 +167,7 @@ export function createNotebookToolDefinitions( return new Text(text, 0, 0); }, - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + async execute(_toolCallId, params: ReadArgs, _signal, _onUpdate, _ctx) { assertFresh(); const content = state.notebookPages.get(params.name); const names = getPageNames(state); @@ -163,7 +176,7 @@ export function createNotebookToolDefinitions( return { content: [ { - type: "text", + type: "text" as const, text: `Notebook page "${params.name}" not found.` + `\n\nNotebook Pages:\n${formatPageList(state) || "(empty)"}`, @@ -176,7 +189,7 @@ export function createNotebookToolDefinitions( return { content: [ { - type: "text", + type: "text" as const, text: `--- ${params.name} ---\n${content}\n` + `---\nNotebook Pages:\n${formatPageList(state) || "(empty)"}`, @@ -187,7 +200,7 @@ export function createNotebookToolDefinitions( }, }; - const notebookIndex: ToolDefinition = { + const notebookIndex: ToolDefinition = { name: "notebook_index", label: "Notebook Index", description: @@ -203,7 +216,7 @@ export function createNotebookToolDefinitions( ], } : {}), - parameters: Type.Object({}), + parameters: notebookIndexParams, renderResult(result, { expanded }, theme, _context) { const entries = (result.details as { entries: string[] }).entries; if (entries.length === 0) { @@ -222,7 +235,7 @@ export function createNotebookToolDefinitions( return { content: [ { - type: "text", + type: "text" as const, text: `Notebook Pages:\n${formatPageList(state) || "(empty)"}`, }, ], diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..944ec5f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3457 @@ +{ + "name": "pi-agenticoding", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-agenticoding", + "version": "0.3.0", + "license": "MIT", + "devDependencies": { + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-coding-agent": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", + "fast-check": "^4.8.0", + "typebox": "^1.2.2", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz", + "integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.26", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.5", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz", + "integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz", + "integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", + "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.45.tgz", + "integrity": "sha512-sJe5ZWibO4s7RWjFQ8Zol76KxoJcIYyEZH1/wxQSBMSIAAxzaJ8cS/ITAaIHWUQvDKQdt18+cJAHKWB7n1Jmrg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-login": "^3.972.45", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz", + "integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.46.tgz", + "integrity": "sha512-cS4w0jzDRb1jOlkiJS3y80OxddHzkky/MN9k3NYs5jganNKVLjF0lpvjlwS118oGMr3cdAfOlVdo8gLurTSE7w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-ini": "^3.972.45", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz", + "integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz", + "integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/token-providers": "3.1056.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1056.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz", + "integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz", + "integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.18.tgz", + "integrity": "sha512-QPQhwY/fstR8fMZFWrsJRNoTP6D1RjRPHGRX7u9/VkF3opCsvD0oXPz6qzkX94SchzvuS5vyFZbJbPcMEs2Jeg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.14.tgz", + "integrity": "sha512-DoZ4djVj/74XQ6M/IwxuKh543tTvLCL7u1Dx+VDHMgW9yGNrFSJJ1l0LrUQRaekic5CB12wUiiOoHL0VI6H0gg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.23.tgz", + "integrity": "sha512-F0d4A9pJFiwljyKgSwU1Z5n+CXSv8bp+V5SthbS2rftB8wBN9z1K2Yyv3xbeK0AM2T0g4q6Ptf0shFF+oQZyiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz", + "integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", + "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", + "integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz", + "integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "integrity": "sha512-CM2pkTs1iupG/maw381lC9Q/Y/aQaMGK7GILc28ttImD0ci3LDwKroDsGkWbly5JIy3iqxdRxB9JlG7vvzCzTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.78.1.tgz", + "integrity": "sha512-Syjf6Ib8UoY5t9ZdKjp0BRrQZuFkFBc8j2KEU9zG/ZnmYPcAxYeioofdv2Q3MEXnHEX2U8sKQptkSnJIdMsd0g==", + "dev": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.78.1", + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.78.1", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", + "integrity": "sha512-07GVQo/38a0yvIPlWDr3RJn1B8gk3ZuIX9h2oIQ+Biyu3JN0KppWmgWHfaWRydQgse5JtC++KDw5MWaIRnV0mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz", + "integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.6.tgz", + "integrity": "sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz", + "integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz", + "integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.2.tgz", + "integrity": "sha512-0nqIJFL+baWoAEtwa0l/vfbfXg0+3gEhiWGnHuoIiivXjlk/TpxDddG0WER34CojKNpHi4ZXku8XGEz9H55b5Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json index 893bea6..86336d7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pi-agenticoding", "version": "0.3.0", "type": "module", - "description": "Context management primitives for the pi coding agent — spawn, ledger, handoff", + "description": "Context management primitives for the pi coding agent — spawn, notebook, handoff", "license": "MIT", "keywords": [ "pi-package" @@ -11,13 +11,28 @@ "type": "git", "url": "git+https://github.com/agenticoding/pi-agenticoding.git" }, + "engines": { + "node": ">=22" + }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-coding-agent": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", + "typebox": "^1.2.2" + }, + "scripts": { + "test": "node ./scripts/run-node-test.mjs tests/unit/**/*.test.ts", + "test:e2e": "node ./scripts/run-node-test.mjs tests/e2e/**/*.test.ts", + "test:all": "npm run test && npm run test:e2e", + "test:snapshots:check": "node ./scripts/run-node-test.mjs tests/unit/render-snapshots.test.ts", + "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts" }, "devDependencies": { + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-coding-agent": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", + "fast-check": "^4.8.0", + "typebox": "^1.2.2", "typescript": "^6.0.3" }, "pi": { diff --git a/register-loader.mjs b/register-loader.mjs new file mode 100644 index 0000000..175aa34 --- /dev/null +++ b/register-loader.mjs @@ -0,0 +1,10 @@ +// Bootstrap module for `--import` that registers the custom module loader. +// Replaces the deprecated `--experimental-loader` flag. +// Phase 1: uses module.register() — available on Node >=22. +// Phase 2: migrate to module.registerHooks() when targeting Node >=25. +import { register } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(resolve(HERE, "test-loader.mjs")), pathToFileURL(HERE + "/")); diff --git a/runtime-singletons.ts b/runtime-singletons.ts new file mode 100644 index 0000000..aa068ca --- /dev/null +++ b/runtime-singletons.ts @@ -0,0 +1,104 @@ +/** + * Shared singleton container for the agenticoding extension. + * + * Allows tests to replace all module-level singletons (write lock, frame + * scheduler, etc.) with one atomic swap via __setSingletons(), instead of + * patching each singleton individually per test. + * + * In production the frame scheduler is registered by spawn/renderer.ts at + * module import time. In tests, createTestHarness() provides a fresh + * container that tests own and dispose. + */ + +import { AsyncLocalStorage } from "node:async_hooks"; + +// ── Types ───────────────────────────────────────────────────────────── + +/** Minimal frame scheduler interface that the container understands. */ +export interface RuntimeFrameScheduler { + markDirty(component: unknown): void; + cancelDirty(component: unknown): void; + flushNow(): void; + clear(): void; + /** Marker property to identify the default noop scheduler. */ + [NOOP_SCHEDULER_MARKER]?: true; +} + +export interface RuntimeWriteLock { + pending: number; + tail: Promise; +} + +export interface RuntimeSingletons { + writeLock: RuntimeWriteLock; + writeContext: AsyncLocalStorage; + frameScheduler: RuntimeFrameScheduler; +} + +export function createWriteLock(): RuntimeWriteLock { + return { + pending: 0, + tail: Promise.resolve(), + }; +} + +// ── Pre‑init defaults (overwritten by spawn/renderer.ts at import time) ── + +/** Sentinel tag to identify the default noop scheduler. */ +const NOOP_SCHEDULER_MARKER = Symbol("no-op-scheduler"); + +function createNoopScheduler(): RuntimeFrameScheduler { + return { + markDirty: () => {}, + cancelDirty: () => {}, + flushNow: () => {}, + clear: () => {}, + [NOOP_SCHEDULER_MARKER]: true, + }; +} + +let current: RuntimeSingletons = { + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: createNoopScheduler(), +}; + +// ── Public API ──────────────────────────────────────────────────────── + +/** Atomically replace all singletons. + * Called by spawn/renderer.ts at module evaluation time (production) and by + * tests via createTestHarness(). The __ prefix signals that callers should + * understand the lifecycle implications — see spawn/renderer.ts for the + * production registration pattern. */ +export function __setSingletons( + s: RuntimeSingletons, + options?: { forceWriteLock?: boolean }, +): void { + if (!options?.forceWriteLock && current.writeLock.pending > 0) { + console.warn( + "[runtime-singletons] writeLock has %d pending operation(s) — " + + "preserving existing lock chain to avoid breaking in-flight writes. " + + "Use { forceWriteLock: true } to override.", + current.writeLock.pending, + ); + // Preserve both lock and ALS context together. Swapping only the context + // breaks reentrancy detection for writers already running inside the old lock. + current = { + ...s, + writeLock: current.writeLock, + writeContext: current.writeContext, + }; + return; + } + current = s; +} + +/** Read the current singleton container. */ +export function getSingletons(): RuntimeSingletons { + return current; +} + +/** True when scheduler is the pre-init noop — see createTestHarness() safety check. */ +export function isNoopScheduler(scheduler: RuntimeFrameScheduler): boolean { + return NOOP_SCHEDULER_MARKER in scheduler; +} diff --git a/scripts/run-node-test.mjs b/scripts/run-node-test.mjs new file mode 100644 index 0000000..4a2ae9b --- /dev/null +++ b/scripts/run-node-test.mjs @@ -0,0 +1,30 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); +const args = process.argv.slice(2); +const updateSnapshots = args.includes("--update-snapshots"); +const patterns = args.filter((arg) => arg !== "--update-snapshots"); + +if (patterns.length === 0) { + throw new Error("Pass at least one test file or glob pattern."); +} + +const loader = pathToFileURL(resolve(root, "register-loader.mjs")).href; +const result = spawnSync( + process.execPath, + ["--import", loader, "--test", ...patterns], + { + cwd: root, + stdio: "inherit", + env: updateSnapshots + ? { ...process.env, UPDATE_SNAPSHOTS: "1" } + : process.env, + }, +); + +if (result.error) throw result.error; +if (result.signal) process.kill(process.pid, result.signal); +process.exit(result.status ?? 1); diff --git a/spawn/index.ts b/spawn/index.ts index d344f8a..2353c03 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -16,6 +16,7 @@ import type { ToolDefinition, ToolInfo, } from "@earendil-works/pi-coding-agent"; +import type { TextContent } from "@earendil-works/pi-ai"; import { AuthStorage, createAgentSession, @@ -45,9 +46,11 @@ const CHILD_MAX_BYTES = 50 * 1024; // ── Helpers ─────────────────────────────────────────────────────────── +// Widen to accept AgentMessage variants from session messages. +// Functions that read these use runtime type checks. type AssistantMessageLike = { role: string; - content?: { type: string; text?: string }[]; + content?: unknown; stopReason?: unknown; }; @@ -208,8 +211,8 @@ export async function executeSpawn( signal: AbortSignal | undefined, onUpdate: | ((result: { - content: { type: string; text: string }[]; - details?: unknown; + content: TextContent[]; + details: unknown; }) => void) | undefined, defaultThinking: ThinkingValue, @@ -327,12 +330,12 @@ export async function executeSpawn( throw invalidatedError; } - const resultText = getLastAssistantText(session.messages); + const resultText = getLastAssistantText(session.messages as AssistantMessageLike[]); if (!resultText) { clearChildSession(); throw new Error("Child agent produced no output."); } - const outcome = wasAborted ? "aborted" : getLastAssistantOutcome(session.messages); + const outcome = wasAborted ? "aborted" : getLastAssistantOutcome(session.messages as AssistantMessageLike[]); const { text: finalText, truncated } = truncateResult(resultText); // Execution should not retain live children after completion. If the TUI @@ -378,7 +381,7 @@ export async function executeSpawn( } return { - content: [{ type: "text" as const, text: finalText }], + content: [{ type: "text" as const, text: finalText }] as TextContent[], details, }; } @@ -414,8 +417,8 @@ export function registerSpawnTool( signal: AbortSignal | undefined, onUpdate: | ((result: { - content: { type: string; text: string }[]; - details?: unknown; + content: TextContent[]; + details: unknown; }) => void) | undefined, ctx: ExtensionContext, diff --git a/spawn/renderer.ts b/spawn/renderer.ts index 00e92e7..5c837a2 100644 --- a/spawn/renderer.ts +++ b/spawn/renderer.ts @@ -27,11 +27,16 @@ import { ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent"; + import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent"; import { Container, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { TUI } from "@earendil-works/pi-tui"; import type { AgenticodingState } from "../state.js"; +import { + __setSingletons, + getSingletons, +} from "../runtime-singletons.js"; import { getLastAssistantText, type SpawnOutcome, @@ -149,7 +154,7 @@ function renderPromptPreview(prompt: string, expanded: boolean): { shown: string */ function safeKeyHint(action: string, fallback: string): string { try { - return keyHint(action, fallback); + return keyHint(action as keyof import("@earendil-works/pi-tui").Keybindings, fallback); } catch { return fallback; } @@ -249,7 +254,7 @@ interface SpawnFrameTarget { * streaming events (50-100+/sec) do not trigger an equal number of heavy * component mutations. */ -class SpawnFrameScheduler { +export class SpawnFrameScheduler { private readonly frameMs: number; private dirtyComponents = new Set(); private frameTimer: ReturnType | null = null; @@ -295,15 +300,30 @@ class SpawnFrameScheduler { this.dirtyComponents.clear(); const requestRenders = new Set<() => void>(); + const failed: SpawnFrameTarget[] = []; + for (const component of batch) { - // 1. Apply accumulated event state to rendering components - component.flushPendingUpdates(); - // 2. Invalidate render cache so render() recomputes on next TUI paint - component.clearRenderCache(); - // 3. Collect TUI invalidate - const r = component.flushScheduledRender(); - if (r) requestRenders.add(r); + try { + // 1. Apply accumulated event state to rendering components + component.flushPendingUpdates(); + // 2. Invalidate render cache so render() recomputes on next TUI paint + component.clearRenderCache(); + // 3. Collect TUI invalidate + const r = component.flushScheduledRender(); + if (r) requestRenders.add(r); + } catch (e) { + // Component failed during flush — re-queue for next frame. + // The error is logged but we continue processing remaining components. + console.error("[spawn] flush error on component:", e); + failed.push(component); + } + } + + // Re-queue failed components for recovery on next frame + for (const component of failed) { + getSingletons().frameScheduler.markDirty(component); } + // One invalidate per distinct callback per frame tick. for (const requestRender of requestRenders) { requestRender(); @@ -316,8 +336,20 @@ class SpawnFrameScheduler { } } -/** Module-level singleton shared by all NestedAgentSessionComponent instances. */ +/** + * Module-level singleton shared by all NestedAgentSessionComponent instances. + * + * Registered into the RuntimeSingletons container at module evaluation time. + * Test harnesses overwrite this with a fresh SpawnFrameScheduler via + * createTestHarness(). ESM guarantees all static imports resolve before any + * module body runs, so the harness always wins. + * + * IMPORTANT: never use dynamic import() to load this module *after* a + * createTestHarness() call, or the production scheduler will overwrite the + * test one. + */ const spawnFrameScheduler = new SpawnFrameScheduler(); +__setSingletons({ ...getSingletons(), frameScheduler: spawnFrameScheduler }); // ── NestedAgentSessionComponent ─────────────────────────────────────── @@ -396,7 +428,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.renderQueued = false; this.queuedRenderToken = undefined; this.renderScheduleToken++; - spawnFrameScheduler.cancelDirty(this); + getSingletons().frameScheduler.cancelDirty(this); } /** @@ -409,7 +441,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget if (this.renderQueued) return; this.renderQueued = true; this.queuedRenderToken = ++this.renderScheduleToken; - spawnFrameScheduler.markDirty(this); + getSingletons().frameScheduler.markDirty(this); } /** @@ -555,7 +587,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget dispose(): void { this.unsubscribe?.(); this.unsubscribe = undefined; - spawnFrameScheduler.cancelDirty(this); + getSingletons().frameScheduler.cancelDirty(this); this.clearPendingState(); // Snapshot fields before clearing: if session.abort() triggers re-entrant // dispose, the nulled-out fields prevent double-abort. @@ -602,7 +634,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget // 1. Apply latest streaming message to the assistant component if (this.pendingAssistantMessage && this.streamingComponent) { try { - this.streamingComponent.updateContent(this.pendingAssistantMessage); + this.streamingComponent.updateContent(this.pendingAssistantMessage as unknown as import("@earendil-works/pi-ai").AssistantMessage); } catch (error) { this.resetStreamingComponent(error, "message_update"); } @@ -677,17 +709,18 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget private addMessageToChat(message: SpawnChildMessage): void { switch (message.role) { case "bashExecution": { - const component = new BashExecutionComponent(message.command, this.fakeUi as unknown as TUI, message.excludeFromContext); + const component = new BashExecutionComponent(message.command ?? "", this.fakeUi as unknown as TUI, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } - component.setComplete(message.exitCode, message.cancelled, message.truncated ? { truncated: true } : undefined, message.fullOutputPath); + component.setComplete(message.exitCode, message.cancelled ?? false, message.truncated ? { truncated: true } as any : undefined, message.fullOutputPath); this.addChild(component); break; } case "custom": { if (message.display) { - const component = new CustomMessageComponent(message, undefined, this.markdownTheme); + // CustomMessage type is internal to the SDK; SpawnChildMessage is structurally compatible. + const component = new CustomMessageComponent(message as any, undefined, this.markdownTheme); component.setExpanded(this.expanded); this.addChild(component); } @@ -718,7 +751,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget break; } case "assistant": { - this.addChild(new AssistantMessageComponent(message, false, this.markdownTheme, "Thinking...")); + this.addChild(new AssistantMessageComponent(message as unknown as import("@earendil-works/pi-ai").AssistantMessage, false, this.markdownTheme, "Thinking...")); break; } case "toolResult": { @@ -731,7 +764,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget if (!this.session) return; // Flush any pending state first so accumulated updates don't double-apply - spawnFrameScheduler.cancelDirty(this); + getSingletons().frameScheduler.cancelDirty(this); this.clearPendingState(); this.clear(); @@ -752,7 +785,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.addMessageToChat(message); for (const content of message.content ?? []) { if (content.type !== "toolCall") continue; - const component = this.createToolComponent(content.name, content.id, content.arguments ?? {}); + const component = this.createToolComponent(content.name ?? "", content.id ?? "", content.arguments ?? {}); this.addToolComponent(component); if (!component) continue; if (stopOutcome) { @@ -761,17 +794,17 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget : message.errorMessage || "Error"; component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); } else { - renderedPendingTools.set(content.id, component); + renderedPendingTools.set(content.id ?? "", component); } } continue; } if (message.role === "toolResult") { - const component = renderedPendingTools.get(message.toolCallId); + const component = renderedPendingTools.get(message.toolCallId ?? ""); if (component) { - component.updateResult(message); - renderedPendingTools.delete(message.toolCallId); + component.updateResult({ ...asToolResult(message), isError: false }); + renderedPendingTools.delete(message.toolCallId ?? ""); } continue; } @@ -919,7 +952,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget private handleMessageStart(event: Extract): void { if (event.message.role === "custom" || event.message.role === "user") { - this.addMessageToChat(event.message); + this.addMessageToChat(event.message as unknown as SpawnChildMessage); return; } if (event.message.role === "assistant") { @@ -967,7 +1000,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget // Cheap per-event: update the live action text preview const textBlock = event.message.content?.find( (c: any) => c.type === "text" && c.text, - ); + ) as { text: string } | undefined; if (textBlock?.text) { const firstLine = textBlock.text.trim().split("\n")[0]; if (firstLine) { @@ -1209,16 +1242,20 @@ export { NestedAgentSessionComponent, renderSpawnCall, renderSpawnResult }; * Synchronously flush all pending spawn frame work. * Exported for tests. Not needed in production — the frame timer handles * everything automatically. + * + * Delegate through getSingletons() so that test harness swaps are respected. */ export function flushSpawnFrameScheduler(): void { - spawnFrameScheduler.flushNow(); + getSingletons().frameScheduler.flushNow(); } /** * Reset the frame scheduler, discarding any pending dirty markers. * Exported for tests. In production the scheduler lifecycle is tied to * component dispose(), so this is never needed. + * + * Delegate through getSingletons() so that test harness swaps are respected. */ export function resetSpawnFrameScheduler(): void { - spawnFrameScheduler.clear(); + getSingletons().frameScheduler.clear(); } diff --git a/spawn/shared.ts b/spawn/shared.ts index 3cc9af9..a38fec0 100644 --- a/spawn/shared.ts +++ b/spawn/shared.ts @@ -10,9 +10,13 @@ export type SpawnResultDetails = { statsUnavailable?: boolean; }; +// Widen content to accept AgentMessage variants (UserMessage may have string content, +// AssistantMessage has (TextContent | ThinkingContent | ToolCall)[] content). +// Functions reading from AgentMessage[] arrays cast via this type at call sites. type AssistantMessageLike = { role: string; - content?: { type: string; text?: string }[]; + content?: unknown; + stopReason?: unknown; }; /** @@ -22,9 +26,10 @@ export function getLastAssistantText(messages: AssistantMessageLike[]): string { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role !== "assistant") continue; - const text = (msg.content ?? []) + const blocks = Array.isArray(msg.content) ? (msg.content as Array>) : []; + const text = blocks .filter((block) => block.type === "text" && typeof block.text === "string") - .map((block) => block.text ?? "") + .map((block) => block.text as string ?? "") .join("\n") .trim(); if (text) return text; diff --git a/test-loader.mjs b/test-loader.mjs index 986e50a..5ff9178 100644 --- a/test-loader.mjs +++ b/test-loader.mjs @@ -1,17 +1,70 @@ import { access } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import path from "node:path"; +import { existsSync, readFileSync } from "node:fs"; + +/** + * Walk up from a start directory to find node_modules/. + * Works regardless of how the package was installed (local vs global). + */ +function findPackageRoot(name, startDir, maxDepth = 50) { + let dir = startDir; + let depth = 0; + while (true) { + if (depth > maxDepth) return null; + const candidate = path.join(dir, "node_modules", name); + if (existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + depth++; + } +} + +const PROJECT_ROOT = path.dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = findPackageRoot( + "@earendil-works/pi-coding-agent", + PROJECT_ROOT, +); +if (!PACKAGE_ROOT) throw new Error("Cannot find @earendil-works/pi-coding-agent package root"); + +function findPackageEntry(name, entry, searchRoot) { + const packageRoot = findPackageRoot(name, searchRoot); + if (!packageRoot) throw new Error(`Cannot find ${name} package root`); + const resolved = path.join(packageRoot, entry); + if (existsSync(resolved)) return resolved; + throw new Error(`Cannot find ${name}/${entry}`); +} + +const TYPEBOX_ROOT = findPackageRoot("typebox", PROJECT_ROOT); +if (!TYPEBOX_ROOT) throw new Error("Cannot find typebox package root"); +const TYPEBOX_EXPORTS = JSON.parse(readFileSync(path.join(TYPEBOX_ROOT, "package.json"), "utf8")).exports; + +function resolveTypeboxSpecifier(specifier) { + const exportKey = specifier === "typebox" ? "." : `./${specifier.slice("typebox/".length)}`; + const exportTarget = TYPEBOX_EXPORTS?.[exportKey]; + const entry = typeof exportTarget === "string" ? exportTarget : exportTarget?.import ?? exportTarget?.default; + if (!entry) throw new Error(`Cannot find ${specifier} export in top-level typebox package`); + const resolved = path.join(TYPEBOX_ROOT, entry); + if (!existsSync(resolved)) throw new Error(`Cannot find ${specifier} at ${resolved}`); + return resolved; +} -const PACKAGE_ROOT = "/Users/ofri/.nvm/versions/node/v24.14.1/lib/node_modules/@earendil-works/pi-coding-agent"; const PACKAGE_ALIASES = { "@earendil-works/pi-coding-agent": `${PACKAGE_ROOT}/dist/index.js`, - "@earendil-works/pi-ai": `${PACKAGE_ROOT}/node_modules/@earendil-works/pi-ai/dist/index.js`, - "@earendil-works/pi-tui": `${PACKAGE_ROOT}/node_modules/@earendil-works/pi-tui/dist/index.js`, - "@earendil-works/pi-agent-core": `${PACKAGE_ROOT}/node_modules/@earendil-works/pi-agent-core/dist/index.js`, - typebox: `${PACKAGE_ROOT}/node_modules/typebox/build/index.mjs`, + "@earendil-works/pi-ai": findPackageEntry("@earendil-works/pi-ai", "dist/index.js", PACKAGE_ROOT), + "@earendil-works/pi-tui": findPackageEntry("@earendil-works/pi-tui", "dist/index.js", PACKAGE_ROOT), + "@earendil-works/pi-agent-core": findPackageEntry("@earendil-works/pi-agent-core", "dist/index.js", PACKAGE_ROOT), }; export async function resolve(specifier, context, defaultResolve) { + // typebox handled before PACKAGE_ALIASES — resolved via exports map, not alias entry. + if (specifier === "typebox" || specifier.startsWith("typebox/")) { + const typeboxPath = resolveTypeboxSpecifier(specifier); + // Tests should use the repo's declared top-level TypeBox package, including subpath exports. + return defaultResolve(pathToFileURL(typeboxPath).href, context, defaultResolve); + } + const packagePath = PACKAGE_ALIASES[specifier]; if (packagePath) { return defaultResolve(pathToFileURL(packagePath).href, context, defaultResolve); diff --git a/tests/e2e/basic.test.ts b/tests/e2e/basic.test.ts new file mode 100644 index 0000000..30f78eb --- /dev/null +++ b/tests/e2e/basic.test.ts @@ -0,0 +1,156 @@ +/** + * Process-isolated E2E tests for the agenticoding extension. + * + * These tests spawn a fresh Node.js process per test case. Process isolation + * means no shared singletons and no console races between test cases. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { ProcessHarness } from "./pty-harness.js"; + +/** + * Create a fresh host, wait for READY, and return the harness. + */ +async function start(): Promise { + const h = new ProcessHarness(); + await h.waitForText("READY"); + return h; +} + +async function withHarness(run: (h: ProcessHarness) => Promise): Promise { + const h = await start(); + try { + await run(h); + } finally { + try { + h.write("exit"); + } catch { + // already dead + } + h.close(); + } +} + +describe("agenticoding E2E", () => { + it("host starts and extension registers", async () => withHarness(async (h) => { + h.write("tools"); + await h.waitForText("OK:"); + + const snap = h.snapshot(); + assert.ok(snap.includes("notebook_write"), "notebook_write tool registered"); + assert.ok(snap.includes("notebook_read"), "notebook_read tool registered"); + assert.ok(snap.includes("notebook_index"), "notebook_index tool registered"); + assert.ok(snap.includes("notebook_topic_set"), "notebook_topic_set tool registered"); + assert.ok(snap.includes("handoff"), "handoff tool registered"); + assert.ok(snap.includes("spawn"), "spawn tool registered"); + })); + + it("notebook write/read round-trip", async () => withHarness(async (h) => { + h.write('tool notebook_write {"name":"my-page","content":"Hello World"}'); + await h.waitForText("OK:Saved notebook page"); + + h.write('tool notebook_read {"name":"my-page"}'); + await h.waitForText("OK:--- my-page ---"); + + const snap = h.snapshot(); + assert.ok(snap.includes("Hello World"), "content persisted"); + })); + + it("notebook index reflects written pages", async () => withHarness(async (h) => { + h.write('tool notebook_write {"name":"page-a","content":"Page A"}'); + await h.waitForText("OK:"); + + h.write("tool notebook_index {}"); + await h.waitForText("page-a"); + + // Second write should appear in index + h.write('tool notebook_write {"name":"page-b","content":"Page B"}'); + await h.waitForText("OK:"); + + h.write("tool notebook_index {}"); + await h.waitForText("page-b"); + + const snap = h.snapshot(); + assert.ok(snap.includes("page-a"), "page-a in index"); + assert.ok(snap.includes("page-b"), "page-b in index"); + })); + + it("notebook_write overwrites existing page", async () => withHarness(async (h) => { + h.write('tool notebook_write {"name":"page","content":"v1"}'); + await h.waitForText("OK:"); + + // Clear accumulated output so we only check the second write/read + h.clear(); + h.write('tool notebook_write {"name":"page","content":"v2"}'); + await h.waitForText("OK:"); + + h.clear(); + h.write('tool notebook_read {"name":"page"}'); + await h.waitForText("OK:--- page ---"); + + const snap = h.snapshot(); + assert.ok(snap.includes("v2"), "overwritten content present"); + assert.ok(!snap.includes("v1"), "old content absent from fresh output"); + })); + + it("notebook topic lifecycle: set via command, agent-set blocked", async () => withHarness(async (h) => { + // Set topic via /notebook command (human-set) + h.write("cmd notebook my-e2e-topic"); + await h.waitForText("OK"); + + // Agent-set should be blocked (human is authoritative) + h.write('tool notebook_topic_set {"topic":"agent-topic"}'); + await h.waitForText("ERR:"); + const snap = h.snapshot(); + assert.ok( + snap.includes("authoritative"), + "human-set topic blocks agent override", + ); + })); + + it("agent-set topic works when unset", async () => withHarness(async (h) => { + // No topic set yet -- agent can set + h.write('tool notebook_topic_set {"topic":"fresh-agent-topic"}'); + await h.waitForText("OK:Active notebook topic:"); + const snap = h.snapshot(); + assert.ok(snap.includes("fresh-agent-topic")); + })); + + it("handoff tool queues handoff state", async () => withHarness(async (h) => { + h.write('tool handoff {"task":"test handoff task","direction":"next-phase"}'); + await h.waitForText("OK:Handoff started"); + })); + + it("commands are registered", async () => withHarness(async (h) => { + h.write("cmds"); + await h.waitForText("OK:"); + + const snap = h.snapshot(); + assert.ok(snap.includes("notebook"), "/notebook command registered"); + assert.ok(snap.includes("handoff"), "/handoff command registered"); + })); + + it("spawn tool errors gracefully without model infrastructure", async () => withHarness(async (h) => { + // Without a real model/session manager, spawn should throw immediately. + h.write('tool spawn {"prompt":"any task"}'); + await h.waitForText("ERR:"); + + const snap = h.snapshot(); + assert.ok(snap.includes("No model") || snap.includes("ERR"), "spawn errors gracefully"); + })); + + it("handles errors gracefully", async () => withHarness(async (h) => { + // Unknown tool + h.write("tool nonexistent {}"); + await h.waitForText("ERR:unknown tool"); + + // Invalid JSON + h.write("tool notebook_write {bad json}"); + await h.waitForText("ERR:invalid json"); + + // Unknown command + h.write("cmd nonexistent"); + await h.waitForText("ERR:unknown command"); + })); +}); diff --git a/tests/e2e/pty-harness.ts b/tests/e2e/pty-harness.ts new file mode 100644 index 0000000..f31a71d --- /dev/null +++ b/tests/e2e/pty-harness.ts @@ -0,0 +1,107 @@ +/** + * pty-harness.ts — Process-isolated child-process harness for E2E tests. + * + * Spawns a fresh Node.js process and communicates over stdin/stdout. Process + * isolation keeps runtime singletons and console output private per test case + * without depending on PTY availability in CI. + */ + +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { isAbsolute, dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, "..", ".."); +const LOADER = pathToFileURL(resolve(ROOT, "register-loader.mjs")).href; + +export const DEFAULT_SCRIPT = resolve(HERE, "test-host.ts"); +const DEFAULT_TIMEOUT_MS = 5000; +const TIMEOUT_MS = parseInt(process.env.E2E_TIMEOUT_MS ?? "", 10) || DEFAULT_TIMEOUT_MS; + +export class ProcessHarness { + private child: ChildProcessWithoutNullStreams; + private output = ""; + private readOffset = 0; + private timeoutMs: number; + private waiters = new Set<() => void>(); + + constructor( + scriptPath = DEFAULT_SCRIPT, + options?: { timeoutMs?: number }, + ) { + this.timeoutMs = options?.timeoutMs ?? TIMEOUT_MS; + + const entry = isAbsolute(scriptPath) ? scriptPath : resolve(ROOT, scriptPath); + + this.child = spawn(process.execPath, ["--import", LOADER, entry], { + cwd: ROOT, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + FORCE_COLOR: "0", + NODE_OPTIONS: "", + }, + }); + + const append = (chunk: string | Buffer) => { + this.output += chunk.toString(); + for (const wake of this.waiters) wake(); + this.waiters.clear(); + }; + + this.child.stdout.on("data", append); + this.child.stderr.on("data", append); + } + + private async waitForOutput(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => { + const wake = () => { + clearTimeout(timer); + this.waiters.delete(wake); + resolve(); + }; + const timer = setTimeout(wake, ms); + this.waiters.add(wake); + }); + } + + /** Wait for a fresh substring to appear after the prior match. */ + async waitForText(text: string): Promise { + const deadline = Date.now() + this.timeoutMs; + while (Date.now() < deadline) { + const index = this.output.indexOf(text, this.readOffset); + if (index !== -1) { + this.readOffset = index + text.length; + return; + } + await this.waitForOutput(deadline - Date.now()); + } + throw new Error( + `waitForText timeout after ${this.timeoutMs}ms looking for fresh \"${text}\".\n` + + `Output so far:\n${this.output}`, + ); + } + + /** Write a line of input to the child process. */ + write(input: string): void { + this.child.stdin.write(input + "\n"); + } + + /** Return all accumulated output since creation or last clear(). */ + snapshot(): string { + return this.output; + } + + /** Clear accumulated output and match cursor, keeping the child running. */ + clear(): void { + this.output = ""; + this.readOffset = 0; + } + + /** Kill the child process. */ + close(): void { + this.child.stdin.end(); + if (!this.child.killed) this.child.kill(); + } +} diff --git a/tests/e2e/test-host.ts b/tests/e2e/test-host.ts new file mode 100644 index 0000000..8a3e0be --- /dev/null +++ b/tests/e2e/test-host.ts @@ -0,0 +1,149 @@ +/** + * test-host.ts — Minimal pi host for process-isolated E2E tests. + * + * Spawned as a child process. Loads the extension, then runs a + * line-oriented REPL on stdin/stdout. + * + * Protocol: + * → cmd [arg] — call a registered command + * → tool — call a registered tool with JSON params + * → tools — list registered tool names + * → cmds — list registered command names + * → exit — graceful shutdown + * + * ← READY\n — sent after extension registration + * ← OK[:payload]\n — success + * ← ERR:message\n — failure + * + * No TUI. All UI-dependent paths are skipped (hasUI=false). + */ + +import { createInterface } from "node:readline"; +import registerAgenticoding from "../../index.js"; +import { createTestPI } from "../unit/helpers.js"; + +// ── Mock ExtensionAPI ───────────────────────────────────────────── +// Uses createTestPI() from the shared test utilities — a minimal object +// that satisfies what index.ts needs at registration time. +// No TUI dependencies — tools and commands access the state through +// the pi object directly. + +const pi = createTestPI(); +const commands = pi.commands; +const tools = pi.tools; + +// Register the extension — this populates pi.commands and pi.tools +registerAgenticoding(pi); + +// ── Mock ExtensionContext for tool/command execution ────────────── + +const mockCtx = { + hasUI: false, + mode: "non-interactive", + cwd: process.cwd(), + ui: { + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + theme: { fg: () => "" }, + select: () => Promise.resolve(undefined), + confirm: () => Promise.resolve(false), + input: () => Promise.resolve(""), + onTerminalInput: () => () => {}, + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + custom: () => Promise.resolve(undefined), + pasteToEditor: () => {}, + setEditorText: () => {}, + getEditorText: () => "", + editor: () => Promise.resolve(""), + addAutocompleteProvider: () => {}, + themes: [], + getTheme: () => undefined, + setTheme: () => ({ ok: true }), + }, + getContextUsage: () => null, + sessionManager: null, + modelRegistry: null, + // Required by spawn tool which checks ctx.model existence before using it + model: undefined, + isIdle: () => true, + signal: new AbortController().signal, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => process.exit(0), + compact: () => {}, + getSystemPrompt: () => "", +} as any; // Type assertion needed: mock intentionally omits some interface fields + +// ── REPL loop ──────────────────────────────────────────────────── + +process.stdout.write("READY\n"); + +const rl = createInterface({ input: process.stdin }); +for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed === "exit") { + process.exit(0); + } else if (trimmed === "tools") { + const names = Array.from(tools.keys()).sort().join(","); + process.stdout.write("OK:" + names + "\n"); + } else if (trimmed === "cmds") { + const names = Array.from(commands.keys()).sort().join(","); + process.stdout.write("OK:" + names + "\n"); + } else if (trimmed.startsWith("tool ")) { + const rest = trimmed.slice(5).trim(); + const spaceIdx = rest.indexOf(" "); + if (spaceIdx === -1) { + process.stdout.write("ERR:usage tool \n"); + continue; + } + const toolName = rest.slice(0, spaceIdx); + const jsonArgs = rest.slice(spaceIdx + 1); + const toolDef = tools.get(toolName); + if (!toolDef) { + process.stdout.write("ERR:unknown tool " + toolName + "\n"); + continue; + } + let params; + try { params = JSON.parse(jsonArgs); } + catch (e: unknown) { + process.stdout.write("ERR:invalid json: " + (e instanceof Error ? e.message : String(e)) + "\n"); + continue; + } + try { + const result = await toolDef.execute("e2e-" + toolName, params, undefined, undefined, mockCtx); + const text = result.content?.map((c: any) => c.text).filter(Boolean).join("\n") || ""; + process.stdout.write("OK:" + text + "\n"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stdout.write("ERR:" + msg + "\n"); + } + } else if (trimmed.startsWith("cmd ")) { + const rest = trimmed.slice(4).trim(); + const spaceIdx = rest.indexOf(" "); + const cmdName = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); + const cmdArg = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1); + const cmdDef = commands.get(cmdName); + if (!cmdDef) { + process.stdout.write("ERR:unknown command " + cmdName + "\n"); + continue; + } + try { + await cmdDef.handler(cmdArg, mockCtx); + process.stdout.write("OK\n"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stdout.write("ERR:" + msg + "\n"); + } + } else { + process.stdout.write("ERR:unknown input\n"); + } +} diff --git a/tests/snapshots/indicator-30.txt b/tests/snapshots/indicator-30.txt new file mode 100644 index 0000000..ed914e8 --- /dev/null +++ b/tests/snapshots/indicator-30.txt @@ -0,0 +1 @@ +[dim:ctx ][accent:30%] \ No newline at end of file diff --git a/tests/snapshots/indicator-50.txt b/tests/snapshots/indicator-50.txt new file mode 100644 index 0000000..d794902 --- /dev/null +++ b/tests/snapshots/indicator-50.txt @@ -0,0 +1 @@ +[dim:ctx ][warning:50%] \ No newline at end of file diff --git a/tests/snapshots/indicator-70.txt b/tests/snapshots/indicator-70.txt new file mode 100644 index 0000000..d7401e1 --- /dev/null +++ b/tests/snapshots/indicator-70.txt @@ -0,0 +1 @@ +[dim:ctx ][error:70%] \ No newline at end of file diff --git a/tests/snapshots/nested-collapsed-running.txt b/tests/snapshots/nested-collapsed-running.txt new file mode 100644 index 0000000..88b674e --- /dev/null +++ b/tests/snapshots/nested-collapsed-running.txt @@ -0,0 +1,2 @@ + gpt-4o • medium + ⏳ initializing… \ No newline at end of file diff --git a/tests/snapshots/nested-collapsed-success.txt b/tests/snapshots/nested-collapsed-success.txt new file mode 100644 index 0000000..445ae6f --- /dev/null +++ b/tests/snapshots/nested-collapsed-success.txt @@ -0,0 +1,3 @@ + ✅ gpt-4o • high + Analysis complete. The optimal solution is to use a cache layer with TTL of... + tok 200/150 · 4t · $0.0450 \ No newline at end of file diff --git a/tests/snapshots/nested-expanded.txt b/tests/snapshots/nested-expanded.txt new file mode 100644 index 0000000..b28d324 --- /dev/null +++ b/tests/snapshots/nested-expanded.txt @@ -0,0 +1,6 @@ + + ✅ gpt-4o • medium + + Here is the implementation plan. Create data access layer, add caching + middleware, wire up the controller. + \ No newline at end of file diff --git a/tests/snapshots/spawn-call-collapsed.txt b/tests/snapshots/spawn-call-collapsed.txt new file mode 100644 index 0000000..cea6994 --- /dev/null +++ b/tests/snapshots/spawn-call-collapsed.txt @@ -0,0 +1,4 @@ + + spawn child [medium] + Research the rate limits for the OpenAI API and document the results. + \ No newline at end of file diff --git a/tests/snapshots/spawn-call-long.txt b/tests/snapshots/spawn-call-long.txt new file mode 100644 index 0000000..fe95eb9 --- /dev/null +++ b/tests/snapshots/spawn-call-long.txt @@ -0,0 +1,7 @@ + + spawn child [high] + Line 1: Initialize the project structure + Line 2: Set up TypeScript configuration + Line 3: Create the main entry point + ... (4 more lines, to expand) + \ No newline at end of file diff --git a/tests/snapshots/spawn-result-aborted.txt b/tests/snapshots/spawn-result-aborted.txt new file mode 100644 index 0000000..0bf8457 --- /dev/null +++ b/tests/snapshots/spawn-result-aborted.txt @@ -0,0 +1,5 @@ + + ✗ gpt-4o-mini • low + 💬 aborted + Operation cancelled by user request. + \ No newline at end of file diff --git a/tests/snapshots/spawn-result-error.txt b/tests/snapshots/spawn-result-error.txt new file mode 100644 index 0000000..c55831b --- /dev/null +++ b/tests/snapshots/spawn-result-error.txt @@ -0,0 +1,5 @@ + + ⚠ gpt-4o • high + 💬 error + Failed to connect to API: rate limit exceeded. Retry after 60 seconds. + \ No newline at end of file diff --git a/tests/snapshots/spawn-result-success.txt b/tests/snapshots/spawn-result-success.txt new file mode 100644 index 0000000..19d8b7c --- /dev/null +++ b/tests/snapshots/spawn-result-success.txt @@ -0,0 +1,5 @@ + + ✅ gpt-4o • medium + 💬 done + Task completed successfully. All tests pass and documentation is updated. + \ No newline at end of file diff --git a/tests/test-utils.ts b/tests/test-utils.ts new file mode 100644 index 0000000..9c3d1e8 --- /dev/null +++ b/tests/test-utils.ts @@ -0,0 +1,106 @@ +/** + * Central test harness for the agenticoding extension. + * + * Every non-E2E test that touches module-level singletons starts with + * `const h = createTestHarness()` and ends with `h.teardown()`. One call + * replaces the singleton container atomically and captures console output — + * no per-test patches. + * + * Usage: + * + * const h = createTestHarness(); + * // test body — use h.warnings + * h.teardown(); + * + * With beforeEach/afterEach: + * + * describe("spawn", () => { + * let h: TestHarness; + * beforeEach(() => { h = createTestHarness(); }); + * afterEach(() => { h.teardown(); }); + * }); + */ + +import { AsyncLocalStorage } from "node:async_hooks"; +import { + __setSingletons, + createWriteLock, + getSingletons, + isNoopScheduler, + type RuntimeSingletons, +} from "../runtime-singletons.js"; +import { SpawnFrameScheduler } from "../spawn/renderer.js"; + +// ── Types ───────────────────────────────────────────────────────────── + +export interface TestHarness { + /** Captured console.warn and console.error calls. */ + warnings: Array<{ level: string; args: unknown[] }>; + /** Restore console, clear scheduler, reset write lock. */ + teardown: () => void; +} + +// ── Factory ─────────────────────────────────────────────────────────── + +/** + * Create a fresh test harness. Every test that needs isolation calls this. + * + * IMPORTANT: Do not call createTestHarness() twice without an intervening + * teardown(). The second call captures the first's state, and teardown of the + * second restores stale singletons. Use beforeEach/afterEach to guarantee a + * single active harness per test. + * + * CRITICAL: ESM static imports resolve before any module body runs. This means + * spawn/renderer.ts registers the production frame scheduler at import time. + * The test harness replaces the frame scheduler with a fresh test scheduler. + * This works correctly as long as test-utils.ts is imported before spawn/renderer.ts + * in the module graph. Never use dynamic import() to load spawn/renderer.ts after + * createTestHarness() — the production scheduler would overwrite the test one. + */ +export function createTestHarness(): TestHarness { + const previousSingletons = getSingletons(); + + const singletons: RuntimeSingletons = { + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: new SpawnFrameScheduler(), + }; + const warnings: Array<{ level: string; args: unknown[] }> = []; + const originalWarn = console.warn; + const originalError = console.error; + + // Check whether spawn/renderer.ts was already statically imported before + // this harness call — if previousSingletons still holds the noop marker, + // the production registration at the bottom of spawn/renderer.ts never ran. + if (isNoopScheduler(previousSingletons.frameScheduler)) { + console.warn( + "[test-utils] spawn/renderer.ts was not statically imported before " + + "createTestHarness() — the production frame scheduler was never " + + "registered. Frame-batched rendering tests will use the noop scheduler.", + ); + } + + // Atomic swap: replace the production singleton container (write lock, + // context, frame scheduler) in one call. + __setSingletons(singletons); + + // Capture console output for assertions without noisy passing-test output. + console.warn = (...args: unknown[]) => { + warnings.push({ level: "warn", args }); + }; + console.error = (...args: unknown[]) => { + warnings.push({ level: "error", args }); + }; + + return { + warnings, + teardown: () => { + // Restore singletons first so the harness scheduler is current. + // Then clear it to release any dirty components before disposal. + __setSingletons(previousSingletons); + singletons.frameScheduler.clear(); + console.warn = originalWarn; + console.error = originalError; + }, + }; +} \ No newline at end of file diff --git a/tests/unit/fixtures/register-loader-entry.mjs b/tests/unit/fixtures/register-loader-entry.mjs new file mode 100644 index 0000000..fded0a2 --- /dev/null +++ b/tests/unit/fixtures/register-loader-entry.mjs @@ -0,0 +1,2 @@ +import "../../../state.js"; +console.log("ok"); diff --git a/tests/unit/handoff.test.ts b/tests/unit/handoff.test.ts new file mode 100644 index 0000000..90b5084 --- /dev/null +++ b/tests/unit/handoff.test.ts @@ -0,0 +1,229 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { registerHandoffCommand } from "../../handoff/command.js"; +import { registerHandoffTool } from "../../handoff/tool.js"; +import { registerHandoffCompaction } from "../../handoff/compact.js"; +import registerAgenticoding from "../../index.js"; +import { STATUS_KEY_HANDOFF, WIDGET_KEY_WARNING, updateIndicators } from "../../tui.js"; +import { createTestPI, makeTUICtx } from "./helpers.js"; + +test("/handoff sends the direction back through the LLM without opening the editor", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { notify: (_message: string) => {} }, + }); + + assert.deepEqual(state.pendingRequestedHandoff, { + direction: "implement auth", + enforcementAttempts: 0, + toolCalled: false, + }); + assert.deepEqual(pi.sentUserMessages, [ + { + content: + "Handoff direction: implement auth\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.", + options: undefined, + }, + ]); +}); + +test("/handoff requires a direction", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + + const notifications: string[] = []; + await pi.commands.get("handoff")!.handler(" ", { + hasUI: true, + isIdle: () => true, + ui: { notify: (message: string) => notifications.push(message) }, + }); + + assert.deepEqual(notifications, ["Usage: /handoff "]); + assert.deepEqual(pi.sentUserMessages, []); +}); + +test("handoff tool triggers compaction and resumes with the compacted task", async () => { + const pi = createTestPI(); + const state = createState(); + state.notebookPages.set("auth-refresh", "sensitive notebook body"); + state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + registerHandoffTool(pi as any, state); + + let compactOptions: any; + const result = await pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue auth-refresh" }, + undefined, + undefined, + { + compact: (options: any) => { + compactOptions = options; + }, + }, + ); + + assert.equal(state.pendingHandoff?.source, "tool"); + // Structural: verify the task contains the handoff template structure, + // not exact phrasing (template wording may evolve). + assert.match(state.pendingHandoff?.task ?? "", /## Handoff/); + assert.match(state.pendingHandoff?.task ?? "", /notebook/i); + assert.match(state.pendingHandoff?.task ?? "", /task|context|situational/i); + // The user's task content is the actual contract — keep exact match. + assert.match(state.pendingHandoff?.task ?? "", /Goal: continue auth-refresh/); + assert.doesNotMatch(state.pendingHandoff?.task ?? "", /sensitive notebook body/); + assert.equal(state.pendingRequestedHandoff?.toolCalled, true); + assert.equal(typeof compactOptions?.onComplete, "function"); + assert.equal(result.content[0].text, "Handoff started."); + assert.equal(result.terminate, true); + + compactOptions.onComplete({}); + assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); +}); + +test("handoff compaction replaces old context with the queued task", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingHandoff = { task: "Goal: continue", source: "tool" }; + state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true }; + state.activeNotebookTopic = "oauth"; + state.activeNotebookTopicSource = "human"; + registerHandoffCompaction(pi as any, state); + + const [handler] = pi.handlers.get("session_before_compact")!; + const result = await handler( + { + preparation: { tokensBefore: 123 }, + branchEntries: [{ id: "leaf-1" }], + }, + {}, + ); + + assert.equal(state.pendingHandoff, null); + assert.equal(state.pendingRequestedHandoff, null); + assert.equal(state.activeNotebookTopic, null); + assert.equal(state.activeNotebookTopicSource, null); + assert.equal(result.compaction.summary, "Goal: continue"); + assert.equal(result.compaction.tokensBefore, 123); + assert.equal(result.compaction.firstKeptEntryId, "leaf-1-handoff-cut"); + assert.deepEqual(result.compaction.details, { handoff: true, task: "Goal: continue" }); +}); + +test("/handoff sets the handoff status indicator", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + const statuses = new Map(); + + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: () => {}, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + }, + }); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff in progress"); +}); + +test("handoff compaction clears the handoff status indicator", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingHandoff = { task: "Goal: continue", source: "tool" }; + registerHandoffCompaction(pi as any, state); + const statuses = new Map(); + const [handler] = pi.handlers.get("session_before_compact")!; + + await handler( + { preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-1" }] }, + { hasUI: true, ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } } }, + ); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); +}); + +test("handoff compaction error clears pending state and status", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + registerHandoffTool(pi as any, state); + let compactOptions: any; + const statuses = new Map(); + + await pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + hasUI: true, + ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + compactOptions.onError({}); + + assert.equal(state.pendingHandoff, null); + assert.equal(state.pendingRequestedHandoff?.toolCalled, false); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); +}); + +test("turn_end fallback clears stale requested handoff status", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const statuses = new Map(); + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: () => {}, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + }, + }); + + const [turnEnd] = pi.handlers.get("turn_end")!; + await turnEnd({}, { + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + setWidget: () => {}, + }, + getContextUsage: () => null, + }); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); +}); + +test("session_start new clears stale handoff status and warning widget", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const statuses = new Map([[STATUS_KEY_HANDOFF, "stale"]]); + const widgets = new Map([[WIDGET_KEY_WARNING, ["stale"]]]); + const sessionStartHandlers = pi.handlers.get("session_start")!; + const ctx = { + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + setWidget: (key: string, value: string[] | undefined) => { widgets.set(key, value); }, + }, + sessionManager: { getBranch: () => [] }, + getContextUsage: () => null, + }; + for (const sessionStart of sessionStartHandlers) { + await sessionStart({ reason: "new" }, ctx); + } + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); +}); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts new file mode 100644 index 0000000..59ae5f2 --- /dev/null +++ b/tests/unit/helpers.ts @@ -0,0 +1,240 @@ +// ── Shared test helpers ────────────────────────────────────────── +// Imported by other test files via `./helpers.js` +// Includes createTestPI(), test utilities, theme constants, etc. + +import type { Theme } from "@earendil-works/pi-coding-agent"; +import assert from "node:assert/strict"; + +export const theme = { + fg: (_name: string, text: string) => text, + bold: (text: string) => text, +} as unknown as Theme; + +export const ansiTheme = { + fg: (_name: string, text: string) => `\u001b[38;5;245m${text}\u001b[39m`, + bg: (_name: string, text: string) => `\u001b[48;5;236m${text}\u001b[49m`, + bold: (text: string) => text, +} as unknown as Theme; + +export function createRenderContext(overrides: Record = {}): Record { + return { + expanded: false, + showImages: true, + toolCallId: "tool-call-1", + lastComponent: undefined, + invalidate: () => {}, + ...overrides, + }; +} + +export function createSession(messages: any[]) { + return { + messages, + subscribe: () => () => {}, + getToolDefinition: () => undefined, + sessionManager: { getCwd: () => process.cwd() }, + abort: async () => {}, + } as unknown as import("@earendil-works/pi-coding-agent").AgentSession; +} + +export function createSubscribableSession(messages: any[] = []) { + let handler: ((event: any) => void) | undefined; + return { + session: { + messages, + subscribe: (cb: (event: any) => void) => { + handler = cb; + return () => { handler = undefined; }; + }, + getToolDefinition: () => undefined, + sessionManager: { getCwd: () => process.cwd() }, + abort: async () => {}, + } as unknown as import("@earendil-works/pi-coding-agent").AgentSession, + emit: (event: any) => handler?.(event), + }; +} + +export function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-9;]*m/g, "").replace(/\u001b\][^\u0007]*\u0007/g, ""); +} + +export function getRenderedLine(lines: string[], match: (plain: string) => boolean): string { + const line = lines.find(candidate => match(stripAnsi(candidate))); + assert.ok(line); + return line; +} + +export function getLineContaining(lines: string[], text: string): string { + const line = lines.find(candidate => candidate.includes(text)); + assert.ok(line); + return line; +} + +export function assertShellBackgroundPreserved(line: string): void { + assert.equal(line.includes("\u001b[0m"), false); + assert.match(line, /\u001b\[48;/); +} + +export function createDeferred() { + let resolve!: () => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +type Handler = (args: any, ctx: any) => any; + +export function createTestPI() { + const _handlers = new Map(); + const _tools = new Map(); + const _commands = new Map(); + const _activeTools: string[] = []; + const _allToolNames: string[] = []; + const _toolSources = new Map(); + const _sentUserMessages: Array<{ content: string; options: any }> = []; + const _appendedEntries: Array<{ customType: string; data: any }> = []; + + const obj = { + registerCommand: (name: string, def: any) => { _commands.set(name, def); }, + registerTool: (def: any) => { _tools.set(def.name, def); }, + on: (event: string, handler: any) => { + const h = _handlers.get(event) ?? []; + h.push(handler); + _handlers.set(event, h); + }, + getActiveTools: () => [..._activeTools], + getAllTools: () => + (_allToolNames.length ? _allToolNames : [..._activeTools]).map((name) => ({ + name, + description: "", + parameters: {}, + sourceInfo: { + path: `<${_toolSources.get(name) ?? "builtin"}:${name}>`, + source: _toolSources.get(name) ?? "builtin", + scope: "temporary" as const, + origin: "top-level" as const, + }, + })), + getThinkingLevel: () => "medium" as const, + setThinkingLevel: () => {}, + sendUserMessage: (content: string, options?: any) => { + _sentUserMessages.push({ content, options }); + }, + appendEntry: (customType: string, data: any) => { + _appendedEntries.push({ customType, data }); + }, + setActiveTools: (tools: string[]) => { + _activeTools.length = 0; + _activeTools.push(...tools); + for (const tool of tools) { + if (!_toolSources.has(tool)) _toolSources.set(tool, "builtin"); + } + }, + setToolSource: (name: string, source: string) => { + _toolSources.set(name, source); + }, + setAllTools: (tools: string[]) => { + _allToolNames.length = 0; + _allToolNames.push(...tools); + for (const tool of tools) { + if (!_toolSources.has(tool)) _toolSources.set(tool, "builtin"); + } + }, + sendMessage: () => Promise.resolve(), + setSessionName: () => {}, + getSessionName: () => undefined, + exec: () => Promise.resolve({ exitCode: 0, stdout: "", stderr: "", code: 0, killed: false, signal: null } as any), + getCommands: () => [], + setModel: () => Promise.resolve(true), + registerProvider: () => {}, + registerShortcut: () => {}, + registerFlag: () => {}, + getFlag: () => undefined, + registerMessageRenderer: () => {}, + setLabel: () => {}, + unregisterProvider: () => {}, + events: { on: () => () => {}, emit: () => {} } as import("@earendil-works/pi-coding-agent").EventBus, + setEditorText: () => {}, + get commands() { return _commands; }, + get tools() { return _tools; }, + get handlers() { return _handlers; }, + get activeTools() { return _activeTools; }, + set activeTools(tools: string[]) { + _activeTools.length = 0; + _activeTools.push(...tools); + }, + get sentUserMessages() { return _sentUserMessages; }, + get appendedEntries() { return _appendedEntries; }, + get allToolNames() { return _allToolNames; }, + get toolSources() { return _toolSources; }, + }; + return obj; +} + +// ── ExtensionAPI compile-time check ────────────────────────────── +// If ExtensionAPI adds new required members, this fails at compile +// time — forcing the test PI factory to be updated in sync. +type _TestPICoversExtensionAPI = typeof createTestPI extends () => import("@earendil-works/pi-coding-agent").ExtensionAPI ? true : never; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _testPIVerified: _TestPICoversExtensionAPI = true; + +export const EMPTY_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +export function createTestAssistantMessage(model: any, content: any[], stopReason = "stop") { + return { + role: "assistant", + content, + api: model.api, + provider: model.provider, + model: model.id, + usage: EMPTY_USAGE, + stopReason, + timestamp: Date.now(), + }; +} + +export function createTestAssistantStream(message: any): any { + return { + async *[Symbol.asyncIterator]() { + yield { type: "done", reason: message.stopReason, message }; + }, + result: async () => message, + }; +} + +export function messageText(message: any): string { + return (message.content ?? []) + .map((block: any) => block.type === "text" ? block.text : JSON.stringify(block)) + .join("\n"); +} + +// ── TUI context factory ─────────────────────────────────────────────── + +export function makeTUICtx( + overrides: Partial<{ + percent: number | null; + hasUI: boolean; + record: { statuses: Map; widgets: Map }; + }> = {}, +): any { + const record = overrides.record ?? { statuses: new Map(), widgets: new Map() }; + const hasUI = overrides.hasUI ?? true; + const percent = overrides.percent !== undefined ? overrides.percent : null; + return { + hasUI, + ui: { + theme: { + fg: (name: string, text: string) => `[${name}:${text}]`, + }, + setStatus: (key: string, status: string | undefined) => { record.statuses.set(key, status); }, + setWidget: (key: string, content: string[] | undefined) => { record.widgets.set(key, content); }, + }, + getContextUsage: () => (percent !== null ? { percent } : null), + }; +} diff --git a/tests/unit/notebook.test.ts b/tests/unit/notebook.test.ts new file mode 100644 index 0000000..521a895 --- /dev/null +++ b/tests/unit/notebook.test.ts @@ -0,0 +1,623 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { Text } from "@earendil-works/pi-tui"; +import { createState, resetState } from "../../state.js"; +import { registerNotebookRehydration } from "../../notebook/rehydration.js"; +import { saveNotebookPage, resetNotebookWriteLock } from "../../notebook/store.js"; +import { createNotebookToolDefinitions } from "../../notebook/tools.js"; +import { __setSingletons, createWriteLock, getSingletons } from "../../runtime-singletons.js"; +import registerAgenticoding from "../../index.js"; +import { STATUS_KEY_TOPIC, WIDGET_KEY_WARNING } from "../../tui.js"; +import { createTestPI, makeTUICtx, createDeferred, theme, stripAnsi } from "./helpers.js"; + +// ── Notebook rehydration tests ──────────────────────────────────────── + +test("notebook rehydration rebuilds the latest epoch and enables notebook tools", async () => { + const pi = createTestPI(); + const state = createState(); + registerNotebookRehydration(pi as any, state); + const [handler] = pi.handlers.get("session_start")!; + + await handler( + {}, + { + sessionManager: { + getBranch: () => [ + { type: "custom", customType: "ledger-entry", data: { epoch: 1, name: "old", content: "old" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 2, name: "keep", content: "new" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 2, name: "keep", content: "newer" } }, + ], + }, + }, + ); + + assert.equal(state.epoch, 2); + assert.deepEqual(Array.from(state.notebookPages.entries()), [["keep", "newer"]]); + assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); +}); + + +test("notebook rehydration rebuilds from the latest persisted epoch and avoids duplicate active tools", async () => { + const pi = createTestPI(); + pi.activeTools = ["read", "notebook_read", "notebook_index"]; + const state = createState(); + state.epoch = 7; + registerNotebookRehydration(pi as any, state); + const [handler] = pi.handlers.get("session_start")!; + + await handler( + {}, + { + sessionManager: { + getBranch: () => [ + { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 7, name: "keep", content: "fresh" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "future", content: "latest" } }, + ], + }, + }, + ); + + assert.equal(state.epoch, 8); + assert.deepEqual(Array.from(state.notebookPages.entries()), [["future", "latest"]]); + assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); +}); + + +test("notebook rehydration clears stale in-memory notebook state when persisted history is empty", async () => { + const pi = createTestPI(); + const state = createState(); + state.epoch = 7; + state.notebookPages.set("stale", "stale body"); + registerNotebookRehydration(pi as any, state); + const [handler] = pi.handlers.get("session_start")!; + + await handler( + {}, + { + sessionManager: { + getBranch: () => [], + }, + }, + ); + + assert.equal(state.epoch, 0); + assert.deepEqual(Array.from(state.notebookPages.entries()), []); + assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); +}); + + +test("session_start rehydrates the latest persisted notebook state through the full hook chain", async () => { + const pi = createTestPI(); + pi.activeTools = ["read", "notebook_read"]; + registerAgenticoding(pi as any); + + const notebookWrite = pi.tools.get("notebook_write"); + await notebookWrite.execute( + "seed", + { name: "stale-page", content: "stale body" }, + undefined, + undefined, + makeTUICtx({ hasUI: false }), + ); + + const sessionStartHandlers = pi.handlers.get("session_start")!; + const ctx = { + hasUI: false, + getContextUsage: () => null, + sessionManager: { + getBranch: () => [ + { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "fresh" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "newer" } }, + ], + }, + }; + for (const sessionStart of sessionStartHandlers) { + await sessionStart({ reason: "resume" }, ctx as any); + } + + const notebookIndex = pi.tools.get("notebook_index"); + const notebookRead = pi.tools.get("notebook_read"); + const indexResult = await notebookIndex.execute("1", {}, undefined, undefined, {} as any); + assert.deepEqual(indexResult.details.entries, ["keep"]); + + const readResult = await notebookRead.execute("2", { name: "keep" }, undefined, undefined, {} as any); + assert.equal(readResult.details.found, true); + assert.equal(readResult.details.body, "newer"); + assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); +}); + +// ── Notebook tool contract tests ────────────────────────────────────── + +test("notebook tools add/get/list return stable contract details", async () => { + const pi = createTestPI(); + const state = createState(); + const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); + + const addResult = await notebookWrite.execute("1", { name: "entry-a", content: "first line\nsecond line" }, undefined, undefined, {} as any); + assert.deepEqual(addResult.details, { entries: ["entry-a"], preview: "first line" }); + assert.equal(state.notebookPages.get("entry-a"), "first line\nsecond line"); + assert.equal(pi.appendedEntries.length, 1); + assert.equal(pi.appendedEntries[0].customType, "notebook-entry"); + assert.equal(pi.appendedEntries[0].data.name, "entry-a"); + + const getResult = await notebookRead.execute("2", { name: "entry-a" }, undefined, undefined, {} as any); + const details = getResult.details as { found: boolean; entries: string[] }; + assert.equal(details.found, true); + assert.deepEqual(details.entries, ["entry-a"]); + assert.match((getResult.content[0] as any).text, /--- entry-a ---/); + assert.match((getResult.content[0] as any).text, /second line/); + + const listResult = await notebookIndex.execute("3", {}, undefined, undefined, {} as any); + assert.deepEqual(listResult.details, { entries: ["entry-a"] }); + assert.match((listResult.content[0] as any).text, /entry-a: first line/); +}); + +test("child notebook tools reject stale access after reset", async () => { + const pi = createTestPI(); + const state = createState(); + state.notebookPages.set("entry-a", "alpha"); + let stale = false; + const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state, { isStale: () => stale }); + + stale = true; + await assert.rejects( + () => notebookWrite.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any), + /invalidated by reset/i, + ); + await assert.rejects( + () => notebookRead.execute("2", { name: "entry-a" }, undefined, undefined, {} as any), + /invalidated by reset/i, + ); + await assert.rejects( + () => notebookIndex.execute("3", {}, undefined, undefined, {} as any), + /invalidated by reset/i, + ); + assert.equal(state.notebookPages.get("entry-a"), "alpha"); + assert.equal(pi.appendedEntries.length, 0); +}); + +test("child notebook_write succeeds while child session is fresh", async () => { + const pi = createTestPI(); + const state = createState(); + const [notebookWrite] = createNotebookToolDefinitions(pi as any, state, { isStale: () => false }); + + const result = await notebookWrite.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any); + assert.deepEqual(result.details, { entries: ["entry-a"], preview: "alpha" }); + assert.equal(state.notebookPages.get("entry-a"), "alpha"); + assert.equal(pi.appendedEntries.length, 1); +}); + +test("notebook_read reports not found with current page names", async () => { + const pi = createTestPI(); + const state = createState(); + state.notebookPages.set("entry-a", "alpha"); + state.notebookPages.set("entry-b", "beta"); + const [, notebookRead] = createNotebookToolDefinitions(pi as any, state); + + const result = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); + assert.deepEqual(result.details, { entries: ["entry-a", "entry-b"], found: false }); + assert.match((result.content[0] as any).text, /Notebook page "missing" not found\./); + assert.match((result.content[0] as any).text, /Notebook Pages:\n/); + assert.match((result.content[0] as any).text, /entry-a: alpha/); + assert.match((result.content[0] as any).text, /entry-b: beta/); +}); + +test("notebook tools show empty-state placeholders", async () => { + const pi = createTestPI(); + const state = createState(); + const [, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); + + const missing = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); + assert.deepEqual(missing.details, { entries: [], found: false }); + assert.match((missing.content[0] as any).text, /Notebook Pages:\n\(empty\)/); + + const list = await notebookIndex.execute("2", {}, undefined, undefined, {} as any); + assert.deepEqual(list.details, { entries: [] }); + assert.match((list.content[0] as any).text, /Notebook Pages:\n\(empty\)/); +}); + +test("notebook_write pushes onUpdate and refreshes UI indicators", async () => { + const pi = createTestPI(); + const state = createState(); + const [notebookWrite] = createNotebookToolDefinitions(pi as any, state); + const record = { statuses: new Map(), widgets: new Map() }; + let update: any; + + const result = await notebookWrite.execute( + "1", + { name: "entry-a", content: "first line\nsecond line" }, + undefined, + (payload: any) => { update = payload; }, + makeTUICtx({ percent: 42, record }), + ); + + assert.equal((update.content[0] as any).text, 'Saved "entry-a": first line'); + assert.deepEqual(update.details, { entries: ["entry-a"], preview: "first line" }); + assert.equal(record.statuses.get("agenticoding-notebook"), "📒 1"); + assert.deepEqual(result.details, { entries: ["entry-a"], preview: "first line" }); +}); + +test("notebook tool renderers expose stable call/result summaries", async () => { + const pi = createTestPI(); + const state = createState(); + const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); + + const addCall = notebookWrite.renderCall!({ name: "entry-a", content: "first line\nsecond line" }, theme, {} as any) as Text; + assert.match(stripAnsi(addCall.render(120).join("\n")), /notebook_write "entry-a": first line/); + + const addResult = notebookWrite.renderResult!( + { content: [{ type: "text", text: "" }], details: { entries: ["entry-a"], preview: "first line" } }, + { expanded: true, isPartial: false }, + theme, + { args: { name: "entry-a", content: "first line\nsecond line" } } as any, + ) as Text; + assert.match(stripAnsi(addResult.render(120).join("\n")), /Saved "entry-a": first line/); + assert.match(stripAnsi(addResult.render(120).join("\n")), /entry-a/); + + const getResult = notebookRead.renderResult!( + { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "body" } }, + { expanded: true, isPartial: false }, + theme, + { args: { name: "entry-a" } } as any, + ) as Text; + assert.match(stripAnsi(getResult.render(120).join("\n")), /"entry-a"/); + assert.match(stripAnsi(getResult.render(120).join("\n")), /body/); + + const getResultWithDelimiters = notebookRead.renderResult!( + { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "line 1\n---\nline 2" } }, + { expanded: true, isPartial: false }, + theme, + { args: { name: "entry-a" } } as any, + ) as Text; + assert.match(stripAnsi(getResultWithDelimiters.render(120).join("\n")), /line 1/); + assert.match(stripAnsi(getResultWithDelimiters.render(120).join("\n")), /line 2/); + + const listResult = notebookIndex.renderResult!( + { content: [{ type: "text", text: "" }], details: { entries: ["entry-a", "entry-b"] } }, + { expanded: true, isPartial: false }, + theme, + {} as any, + ) as Text; + assert.match(stripAnsi(listResult.render(120).join("\n")), /2 pages/); + assert.match(stripAnsi(listResult.render(120).join("\n")), /entry-a/); + assert.match(stripAnsi(listResult.render(120).join("\n")), /entry-b/); +}); + +// ── Notebook command / overlay tests ────────────────────────────────── + +test("/notebook exits cleanly when headless", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + + await assert.doesNotReject(() => pi.commands.get("notebook")!.handler("", { hasUI: false })); +}); + + +test("/notebook notifies with info on first set and warning on boundary change", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: Array<{ message: string; level: string }> = []; + const statuses = new Map(); + const widgets = new Map(); + const ctx = { + hasUI: true, + getContextUsage: () => ({ percent: 20 }), + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + setStatus: (key: string, status: string | undefined) => { statuses.set(key, status); }, + setWidget: (key: string, content: string[] | undefined) => { widgets.set(key, content); }, + }, + }; + + await pi.commands.get("notebook")!.handler("oauth", ctx as any); + await pi.commands.get("notebook")!.handler("billing", ctx as any); + + assert.deepEqual(notifications[0], { message: "Active notebook topic: oauth", level: "info" }); + assert.match(notifications[1].message, /Active notebook topic changed: oauth → billing/); + assert.equal(notifications[1].level, "warning"); + assert.equal(statuses.get(STATUS_KEY_TOPIC), "🧭 billing"); + assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); +}); + +test("/notebook empty overlay renders empty state and closes on input", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + let overlay: any; + let doneCalls = 0; + + await pi.commands.get("notebook")!.handler("", { + hasUI: true, + ui: { + theme, + custom: async (build: any) => { + overlay = build({ requestRender: () => {} }, theme, {}, () => { doneCalls++; }); + }, + }, + }); + + const lines = stripAnsi(overlay.render(120).join("\n")); + assert.match(lines, /Notebook \(0 pages\)/); + assert.match(lines, /\(empty\) — use notebook_write to create pages/); + overlay.handleInput("x"); + assert.equal(doneCalls, 1); +}); + +test("/notebook selection previews the chosen entry", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + const notebookWrite = pi.tools.get("notebook_write"); + await notebookWrite.execute("1", { name: "alpha", content: "body line\nsecond line" }, undefined, undefined, makeTUICtx()); + let overlay: any; + let doneCalls = 0; + + await pi.commands.get("notebook")!.handler("", { + hasUI: true, + ui: { + theme, + custom: async (build: any) => { + overlay = build({ requestRender: () => {} }, theme, {}, () => { doneCalls++; }); + }, + notify: (message: string) => { notifications.push(message); }, + }, + }); + + // First Enter selects the entry — shows body inline, done() not yet called + overlay.handleInput("\r"); + assert.equal(doneCalls, 0, "body shown inline, overlay stays open"); + const bodyLines = stripAnsi(overlay.render(120).join("\n")); + assert.match(bodyLines, /body line/); + assert.match(bodyLines, /alpha/); + + // Second keypress closes the overlay + overlay.handleInput("\r"); + assert.equal(doneCalls, 1); +}); + +test("/notebook overlay sorts entries consistently", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notebookWrite = pi.tools.get("notebook_write"); + await notebookWrite.execute("1", { name: "zeta", content: "last" }, undefined, undefined, makeTUICtx()); + await notebookWrite.execute("2", { name: "alpha", content: "first" }, undefined, undefined, makeTUICtx()); + let overlay: any; + + await pi.commands.get("notebook")!.handler("", { + hasUI: true, + ui: { + theme, + custom: async (build: any) => { + overlay = build({ requestRender: () => {} }, theme, {}, () => {}); + }, + notify: () => {}, + }, + }); + + const lines = stripAnsi(overlay.render(120).join("\n")); + assert.ok(lines.indexOf("alpha") < lines.indexOf("zeta"), lines); +}); + +// ── saveNotebookPage tests ──────────────────────────────────────────── + +test("saveNotebookPage serializes concurrent writes and preserves completion order", async () => { + const pi = createTestPI(); + const state = createState(); + const firstGate = createDeferred(); + const order: string[] = []; + + const first = saveNotebookPage(pi as any, state, "entry-a", "first", async () => { + order.push("first:start"); + await firstGate.promise; + order.push("first:end"); + }); + const second = saveNotebookPage(pi as any, state, "entry-a", "second", async () => { + order.push("second:start"); + }); + + await Promise.resolve(); + assert.deepEqual(order, ["first:start"]); + firstGate.resolve(); + await Promise.all([first, second]); + + assert.deepEqual(order, ["first:start", "first:end", "second:start"]); + assert.equal(state.notebookPages.get("entry-a"), "second"); + assert.deepEqual(pi.appendedEntries.map((entry) => entry.data.content), ["first", "second"]); +}); + +test("saveNotebookPage keeps write order across runtime singleton swaps", async () => { + const pi = createTestPI(); + const state = createState(); + const previousSingletons = getSingletons(); + const firstGate = createDeferred(); + const order: string[] = []; + + try { + const first = saveNotebookPage(pi as any, state, "entry-a", "first", async () => { + order.push("first:start"); + await firstGate.promise; + order.push("first:end"); + }); + await Promise.resolve(); + + __setSingletons({ + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: getSingletons().frameScheduler, + }); + const second = saveNotebookPage(pi as any, state, "entry-a", "second", async () => { + order.push("second:start"); + }); + + await Promise.resolve(); + assert.deepEqual(order, ["first:start"]); + firstGate.resolve(); + await Promise.all([first, second]); + + assert.deepEqual(order, ["first:start", "first:end", "second:start"]); + assert.equal(state.notebookPages.get("entry-a"), "second"); + } finally { + firstGate.resolve(); + resetNotebookWriteLock(); + __setSingletons(previousSingletons, { forceWriteLock: true }); + } +}); + +test("saveNotebookPage rejects true reentrancy explicitly", async () => { + const pi = createTestPI(); + const state = createState(); + + await assert.rejects( + () => saveNotebookPage(pi as any, state, "outer", "outer", async () => { + await saveNotebookPage(pi as any, state, "inner", "inner"); + }), + /not reentrant/i, + ); + assert.equal(state.notebookPages.size, 0); +}); + +test("saveNotebookPage stays non-reentrant across runtime singleton swaps", async () => { + const pi = createTestPI(); + const state = createState(); + const previousSingletons = getSingletons(); + + try { + await assert.rejects( + () => Promise.race([ + saveNotebookPage(pi as any, state, "outer", "outer", async () => { + __setSingletons({ + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: getSingletons().frameScheduler, + }); + await saveNotebookPage(pi as any, state, "inner", "inner"); + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("timeout")), 1000); + }), + ]), + /not reentrant/i, + ); + assert.equal(state.notebookPages.size, 0); + } finally { + resetNotebookWriteLock(); + __setSingletons(previousSingletons, { forceWriteLock: true }); + } +}); + +test("saveNotebookPage releases the lock when assertWritable throws", async () => { + const pi = createTestPI(); + const state = createState(); + + await assert.rejects( + () => saveNotebookPage(pi as any, state, "broken", "value", async () => { + throw new Error("blocked"); + }), + /blocked/, + ); + await assert.doesNotReject(() => saveNotebookPage(pi as any, state, "fresh", "value")); + assert.equal(state.notebookPages.get("fresh"), "value"); +}); + +test("resetNotebookWriteLock clears abandoned lock state for later writes", async () => { + const pi = createTestPI(); + const state = createState(); + const gate = createDeferred(); + void saveNotebookPage(pi as any, state, "stuck", "value", async () => { + await gate.promise; + }); + await Promise.resolve(); + resetNotebookWriteLock(); + + await assert.doesNotReject(() => saveNotebookPage(pi as any, state, "fresh", "value")); + assert.equal(state.notebookPages.get("fresh"), "value"); + gate.resolve(); +}); + + +test("saveNotebookPage truncates oversized content before persisting", async () => { + const pi = createTestPI(); + const state = createState(); + const content = "first line\n" + "detail\n".repeat(3000); + + const result = await saveNotebookPage(pi as any, state, "large-page", content); + const persisted = pi.appendedEntries[0].data.content; + + assert.ok(persisted.length < content.length, "oversized notebook content should be truncated"); + assert.equal(state.notebookPages.get("large-page"), persisted); + assert.equal(result.preview, "first line"); + assert.match(persisted, /^first line/m); +}); + + +test("resetState clears epoch and the next notebook write starts a fresh generation", async () => { + const pi = createTestPI(); + const state = createState(); + const originalNow = Date.now; + + try { + Date.now = () => 1000; + await saveNotebookPage(pi as any, state, "entry-a", "first"); + await saveNotebookPage(pi as any, state, "entry-b", "second"); + assert.equal(state.epoch, 1000); + assert.equal(pi.appendedEntries[0].data.epoch, 1000); + assert.equal(pi.appendedEntries[1].data.epoch, 1000); + + resetState(state); + assert.equal(state.epoch, 0); + + Date.now = () => 2000; + await saveNotebookPage(pi as any, state, "entry-c", "third"); + assert.equal(state.epoch, 2000); + assert.equal(pi.appendedEntries[2].data.epoch, 2000); + } finally { + Date.now = originalNow; + } +}); + +// ── Notebook tool definition metadata tests ─────────────────────────── + +test("notebook tool definitions include prompt hints when withPromptHints is true", () => { + const pi = createTestPI(); + const state = createState(); + const tools = createNotebookToolDefinitions(pi as any, state, { withPromptHints: true }); + + for (const tool of tools) { + assert.ok(typeof tool.promptSnippet === "string", `${tool.name} should have promptSnippet when withPromptHints=true`); + assert.ok(Array.isArray(tool.promptGuidelines), `${tool.name} should have promptGuidelines when withPromptHints=true`); + } + const notebookWrite = tools.find(t => t.name === "notebook_write")!; + const notebookRead = tools.find(t => t.name === "notebook_read")!; + const notebookIndex = tools.find(t => t.name === "notebook_index")!; + + // Structural invariants: all guidelines exist and are non-trivial + for (const tool of tools) { + assert.ok(tool.promptGuidelines!.length >= 2, `${tool.name} should have at least 2 promptGuidelines`); + assert.ok(tool.promptGuidelines!.every((g: string) => g.length > 10), `${tool.name} each guideline should be non-trivial`); + } + + // Conceptual: notebook_write is future-context oriented + const writeGuidelines = notebookWrite.promptGuidelines!.join(" "); + assert.match(writeGuidelines, /subject-oriented pages/i); + assert.match(writeGuidelines, /fresh context/i); + assert.match(writeGuidelines, /belongs in handoff/i); + + // Conceptual: descriptions mention the notebook-page metaphor + assert.match(notebookWrite.description, /page|future contexts/i); + assert.match(notebookRead.description, /notebook page|page/i); + assert.match(notebookIndex.description, /notebook index|index/i); +}); + +test("notebook tool definitions omit prompt hints by default", () => { + const pi = createTestPI(); + const state = createState(); + const tools = createNotebookToolDefinitions(pi as any, state); + + for (const tool of tools) { + assert.equal(tool.promptSnippet, undefined, `${tool.name} should not have promptSnippet by default`); + assert.equal(tool.promptGuidelines, undefined, `${tool.name} should not have promptGuidelines by default`); + } +}); diff --git a/tests/unit/register-loader.test.ts b/tests/unit/register-loader.test.ts new file mode 100644 index 0000000..d174921 --- /dev/null +++ b/tests/unit/register-loader.test.ts @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, "..", ".."); +const REGISTER_LOADER = pathToFileURL(resolve(ROOT, "register-loader.mjs")).href; +const ENTRY = fileURLToPath(new URL("./fixtures/register-loader-entry.mjs", import.meta.url)); + +test("register-loader resolves test-loader relative to itself instead of cwd", () => { + const cwd = mkdtempSync(resolve(tmpdir(), "pi-agenticoding-loader-")); + + try { + const result = spawnSync( + process.execPath, + ["--import", REGISTER_LOADER, ENTRY], + { + cwd, + encoding: "utf8", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /ok/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("register-loader errors when entry file does not exist", () => { + const cwd = mkdtempSync(resolve(tmpdir(), "pi-agenticoding-loader-fail-")); + try { + const result = spawnSync( + process.execPath, + ["--import", REGISTER_LOADER, "/nonexistent/entry.mjs"], + { + cwd, + encoding: "utf8", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + + assert.notEqual(result.status, 0, "should exit non-zero for missing entry"); + // Node.js always includes the path in ENOENT errors, so checking for "nonexistent" is sufficient + assert.ok( + result.stderr.includes("nonexistent"), + "stderr should reference the missing file, got: " + result.stderr, + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("register-loader resolves typebox exports from the project dependency tree", () => { + const cwd = mkdtempSync(resolve(tmpdir(), "pi-agenticoding-loader-typebox-")); + try { + const result = spawnSync( + process.execPath, + [ + "--import", + REGISTER_LOADER, + "--input-type=module", + "-e", + [ + 'const specifiers = ["typebox", "typebox/compile", "typebox/value"];', + "const mods = await Promise.all(specifiers.map((specifier) => import(specifier)));", + 'if (typeof mods[0].Type.String !== "function") process.exit(1);', + "console.log(JSON.stringify(Object.fromEntries(specifiers.map((specifier) => [specifier, import.meta.resolve(specifier)]))));", + ].join("\n"), + ], + { + cwd, + encoding: "utf8", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const resolved = JSON.parse(result.stdout.trim()) as Record; + const [rootSpecifier, ...subpathSpecifiers] = Object.keys(resolved); + const typeboxRoot = resolved[rootSpecifier].replace(/(?:build\/)?index\.m?js$/, ""); + assert.match(typeboxRoot, /^file:/); + for (const specifier of Object.keys(resolved)) { + assert.match(resolved[specifier], /^file:/); + assert.ok( + resolved[specifier].startsWith(typeboxRoot), + `${specifier} should resolve from the same typebox package root`, + ); + } + assert.ok(subpathSpecifiers.length > 0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("register-loader surfaces a clear error for missing typebox exports", () => { + const cwd = mkdtempSync(resolve(tmpdir(), "pi-agenticoding-loader-typebox-missing-")); + try { + const result = spawnSync( + process.execPath, + [ + "--import", + REGISTER_LOADER, + "--input-type=module", + "-e", + 'await import("typebox/not-real");', + ], + { + cwd, + encoding: "utf8", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + + assert.notEqual(result.status, 0, "should exit non-zero for missing typebox export"); + assert.match(result.stderr, /Cannot find typebox\/not-real export/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/render-snapshots.test.ts b/tests/unit/render-snapshots.test.ts new file mode 100644 index 0000000..7b79c22 --- /dev/null +++ b/tests/unit/render-snapshots.test.ts @@ -0,0 +1,314 @@ +/** + * Snapshot tests for TUI render output. + * + * Creates golden files in tests/snapshots/ for every render variant. + * Use UPDATE_SNAPSHOTS=1 to create/update golden files. + * + * No MockPi needed — uses real Theme, real TUI components via the harness. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { createState, type AgenticodingState } from "../../state.js"; +import { + renderSpawnCall, + renderSpawnResult, +} from "../../spawn/renderer.js"; +import { updateIndicators } from "../../tui.js"; +import { createTestHarness } from "../test-utils.js"; +import { createSession, makeTUICtx } from "./helpers.js"; + +// ── Paths ───────────────────────────────────────────────────────────── + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SNAPSHOT_DIR = join(__dirname, "..", "snapshots"); + +// ── Render test backend ─────────────────────────────────────────────── + +class RenderTestBackend { + lines: string[] = []; + render(component: { render(w: number): string[] }, width = 80): this { + this.lines = component.render(width); + return this; + } + toSnapshot(): string { + return this.lines.join("\n"); + } +} + +// ── Theme: identity (no styling tokens, clean golden files) ─────────── + +const theme: Theme = { + fg: (_name: string, text: string) => text, + bold: (text: string) => text, +} as unknown as Theme; + +// ── Snapshot helpers ────────────────────────────────────────────────── + +function ensureSnapshotDir(): void { + if (!existsSync(SNAPSHOT_DIR)) { + mkdirSync(SNAPSHOT_DIR, { recursive: true }); + } +} + +/** Normalize line endings so golden files (stored with \n) match on Windows (\r\n). */ +function normalizeEOL(s: string): string { + return s.replace(/\r?\n/g, "\n"); +} + +/** Strip OSC terminal escape sequences for portable snapshot comparison. */ +function stripOSC(s: string): string { + return s.replace(/\u001b\]133;[A-Z][^\u0007]*\u0007/g, ""); +} + +function matchSnapshot(name: string, actual: string): void { + ensureSnapshotDir(); + const file = join(SNAPSHOT_DIR, `${name}.txt`); + const cleaned = stripOSC(normalizeEOL(actual)); + if (process.env.UPDATE_SNAPSHOTS) { + writeFileSync(file, cleaned); + return; + } + if (!existsSync(file)) { + assert.fail(`Snapshot ${name} is missing. Re-run with UPDATE_SNAPSHOTS=1 to create it.`); + } + const expected = normalizeEOL(readFileSync(file, "utf-8")); + assert.equal(cleaned, expected, `Snapshot ${name} does not match`); +} + +function withHarness(run: (state: AgenticodingState) => void): void { + const harness = createTestHarness(); + const state = createState(); + try { + run(state); + } finally { + harness.teardown(); + } +} + +// ── Snapshot width ──────────────────────────────────────────────────── + +const SNAP_WIDTH = 80; + +// ═══════════════════════════════════════════════════════════════════════ +// 1–2: Spawn call (renderSpawnCall) +// ═══════════════════════════════════════════════════════════════════════ + +test("spawn call collapsed matches snapshot", () => { + const component = renderSpawnCall( + { prompt: "Research the rate limits for the OpenAI API and document the results.", thinking: "medium" }, + theme, + { expanded: false }, + ); + + const rtb = new RenderTestBackend().render(component, SNAP_WIDTH); + matchSnapshot("spawn-call-collapsed", rtb.toSnapshot()); +}); + +test("spawn call long prompt matches snapshot", () => { + const prompt = [ + "Line 1: Initialize the project structure", + "Line 2: Set up TypeScript configuration", + "Line 3: Create the main entry point", + "Line 4: Add test infrastructure", + "Line 5: Configure CI/CD pipeline", + "Line 6: Add documentation", + "Line 7: Final review and cleanup", + ].join("\n"); + + const component = renderSpawnCall( + { prompt, thinking: "high" }, + theme, + { expanded: false }, + ); + + const rtb = new RenderTestBackend().render(component, SNAP_WIDTH); + matchSnapshot("spawn-call-long", rtb.toSnapshot()); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// 3–5: Spawn result (renderSpawnResult, static Text path, no child session) +// ═══════════════════════════════════════════════════════════════════════ + +test("spawn result success matches snapshot", () => withHarness((state) => { + const component = renderSpawnResult( + { + content: [{ type: "text", text: "Task completed successfully. All tests pass and documentation is updated." }], + details: { + model: "gpt-4o", + thinking: "medium", + outcome: "success" as const, + stats: { inputTokens: 150, outputTokens: 75, turns: 3, cost: 0.023 }, + }, + }, + false, + theme, + { toolCallId: "tc-1", invalidate: () => {}, showImages: false, lastComponent: undefined }, + state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("spawn-result-success", rtb.toSnapshot()); +})); + +test("spawn result error matches snapshot", () => withHarness((state) => { + const component = renderSpawnResult( + { + content: [{ type: "text", text: "Failed to connect to API: rate limit exceeded. Retry after 60 seconds." }], + details: { + model: "gpt-4o", + thinking: "high", + outcome: "error" as const, + stats: { inputTokens: 42, outputTokens: 0, turns: 1, cost: 0.0042 }, + }, + }, + false, + theme, + { toolCallId: "tc-2", invalidate: () => {}, showImages: false, lastComponent: undefined }, + state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("spawn-result-error", rtb.toSnapshot()); +})); + +test("spawn result aborted matches snapshot", () => withHarness((state) => { + const component = renderSpawnResult( + { + content: [{ type: "text", text: "Operation cancelled by user request." }], + details: { + model: "gpt-4o-mini", + thinking: "low", + outcome: "aborted" as const, + stats: { inputTokens: 10, outputTokens: 0, turns: 0, cost: 0.0005 }, + }, + }, + false, + theme, + { toolCallId: "tc-3", invalidate: () => {}, showImages: false, lastComponent: undefined }, + state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("spawn-result-aborted", rtb.toSnapshot()); +})); + +// ═══════════════════════════════════════════════════════════════════════ +// 6–8: NestedAgentSessionComponent (via renderSpawnResult with child session) +// ═══════════════════════════════════════════════════════════════════════ + +test("nested collapsed running matches snapshot", () => withHarness((state) => { + const session = createSession([]); + state.childSessions.set("tc-nested-1", session); + + const component = renderSpawnResult( + { + content: [{ type: "text", text: "" }], + details: { model: "gpt-4o", thinking: "medium" }, + }, + false, + theme, + { toolCallId: "tc-nested-1", invalidate: () => {}, showImages: false, lastComponent: undefined }, + state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("nested-collapsed-running", rtb.toSnapshot()); +})); + +test("nested collapsed success matches snapshot", () => withHarness((state) => { + const session = createSession([ + { + role: "assistant", + content: [{ type: "text", text: "Analysis complete. The optimal solution is to use a cache layer with TTL of 300s." }], + }, + ]); + state.childSessions.set("tc-nested-2", session); + + const component = renderSpawnResult( + { + content: [{ type: "text", text: "" }], + details: { + model: "gpt-4o", + thinking: "high", + outcome: "success" as const, + stats: { inputTokens: 200, outputTokens: 150, turns: 4, cost: 0.045 }, + }, + }, + false, + theme, + { toolCallId: "tc-nested-2", invalidate: () => {}, showImages: false, lastComponent: undefined }, + state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("nested-collapsed-success", rtb.toSnapshot()); +})); + +test("nested expanded matches snapshot", () => withHarness((state) => { + const session = createSession([ + { + role: "assistant", + content: [{ type: "text", text: "Here is the implementation plan. Create data access layer, add caching middleware, wire up the controller." }], + }, + ]); + state.childSessions.set("tc-nested-3", session); + + const component = renderSpawnResult( + { + content: [{ type: "text", text: "" }], + details: { + model: "gpt-4o", + thinking: "medium", + outcome: "success" as const, + stats: { inputTokens: 100, outputTokens: 50, turns: 2, cost: 0.012 }, + }, + }, + true, + theme, + { toolCallId: "tc-nested-3", invalidate: () => {}, showImages: false, lastComponent: undefined }, + state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("nested-expanded", rtb.toSnapshot()); +})); + +// ═══════════════════════════════════════════════════════════════════════ +// 9–11: Context indicator snapshots +// ═══════════════════════════════════════════════════════════════════════ + +test("context indicator at 30% matches snapshot", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 30, record }); + + updateIndicators(ctx, state); + const status = record.statuses.get("agenticoding-ctx") ?? ""; + matchSnapshot("indicator-30", status); +}); + +test("context indicator at 50% matches snapshot", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 50, record }); + + updateIndicators(ctx, state); + const status = record.statuses.get("agenticoding-ctx") ?? ""; + matchSnapshot("indicator-50", status); +}); + +test("context indicator at 70% matches snapshot", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 70, record }); + + updateIndicators(ctx, state); + const status = record.statuses.get("agenticoding-ctx") ?? ""; + matchSnapshot("indicator-70", status); +}); diff --git a/tests/unit/runtime-singletons.test.ts b/tests/unit/runtime-singletons.test.ts new file mode 100644 index 0000000..7fcd35d --- /dev/null +++ b/tests/unit/runtime-singletons.test.ts @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { createTestHarness } from "../test-utils.js"; +import { + __setSingletons, + createWriteLock, + getSingletons, + isNoopScheduler, +} from "../../runtime-singletons.js"; + +test("createTestHarness swaps singleton state atomically and restores it on teardown", () => { + const before = getSingletons(); + const h = createTestHarness(); + const during = getSingletons(); + + assert.notEqual(during, before); + assert.notEqual(during.writeContext, before.writeContext); + assert.notEqual(during.frameScheduler, before.frameScheduler); + + h.teardown(); + assert.equal(getSingletons(), before); +}); + +test("__setSingletons warns and preserves lock + write context during in-flight writes", () => { + // Use harness to isolate the test's singleton manipulation + const h = createTestHarness(); + try { + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (msg: string) => { + warnings.push(msg); + }; + try { + const before = getSingletons(); + before.writeLock.pending = 1; // simulate in-flight write on test singleton + __setSingletons({ + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: before.frameScheduler, + }); + const after = getSingletons(); + assert.ok(warnings.length > 0); + assert.match(warnings[0], /pending/); + assert.equal(after.writeLock, before.writeLock); + assert.equal(after.writeContext, before.writeContext); + } finally { + console.warn = originalWarn; + } + } finally { + h.teardown(); + } +}); + +test("write lock serializes concurrent writers and completes all", async () => { + const h = createTestHarness(); + const s = getSingletons(); + + const order: number[] = []; + const writers = Array.from({ length: 5 }, (_, i) => + (async () => { + // Grab the current tail promise before acquiring the lock + const prev = s.writeLock.tail; + // Simulate acquiring the lock by chaining onto the tail + let release: () => void; + const next = new Promise((resolve) => { release = resolve; }); + s.writeLock.pending += 1; + s.writeLock.tail = next; + await prev; + order.push(i); + s.writeLock.pending -= 1; + release!(); + })(), + ); + + await Promise.all(writers); + + // All writers completed in some order + assert.equal(order.length, 5); + assert.ok(order.includes(0)); + assert.ok(order.includes(4)); + + // Order is deterministic (no concurrent completion — serialized by lock) + // If lock works, order is a strict permutation of [0,1,2,3,4] + const sorted = [...order].sort((a, b) => a - b); + assert.deepEqual(order, sorted, "lock must serialize writers — no concurrent completion"); + + h.teardown(); +}); + +test("isNoopScheduler returns false for SpawnFrameScheduler", () => { + // The noop scheduler is created at module init but overwritten by + // spawn/renderer.ts at import time — so the global singleton always + // holds a real scheduler. The true path (returns true) is exercised by + // the import-order guard in createTestHarness() — see test-utils.ts. + assert.equal(isNoopScheduler(getSingletons().frameScheduler), false); + + const h = createTestHarness(); + assert.equal(isNoopScheduler(getSingletons().frameScheduler), false); + h.teardown(); +}); diff --git a/tests/unit/spawn-event.test.ts b/tests/unit/spawn-event.test.ts new file mode 100644 index 0000000..45feaf9 --- /dev/null +++ b/tests/unit/spawn-event.test.ts @@ -0,0 +1,477 @@ +import test, { afterEach, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { createSession, createSubscribableSession, createTestPI, createRenderContext, theme } from "./helpers.js"; +import { flushSpawnFrameScheduler } from "../../spawn/renderer.js"; +import { registerSpawnTool } from "../../spawn/index.js"; +import { createTestHarness, type TestHarness } from "../test-utils.js"; + +let h: TestHarness; + +function makeChildSpawnTool(state: any) { + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + return pi.tools.get("spawn"); +} + +beforeEach(() => { + h = createTestHarness(); +}); + +afterEach(() => { + h.teardown(); +}); + +test("nested spawn live action tracks tool execution events", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // message_start → thinking + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + let lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking, got: ${lines.join("\n")}`); + + // message_update with text → live preview + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "writing code now" }] } }); + lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("writing code now")), `expected live text preview, got: ${lines.join("\n")}`); + + // message_end → success marker in identity line + emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "summary" }], stopReason: "end_turn" } }); + lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("✅")), `expected success marker, got: ${lines.join("\n")}`); + + // Tool events degrade gracefully in minimal test env and still update live action + emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); + lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("[bash]")), `expected tool live action, got: ${lines.join("\n")}`); +}); + +test("nested spawn handleEvent recovers from malformed events", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Emit a malformed event that will throw inside handleEvent + emit({ type: "message_start", message: null }); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[1]), /message_start/); + + // Subsequent valid events still process + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking after recovery, got: ${lines.join("\n")}`); +}); + +test("nested spawn message_end with aborted stopReason clears pending tools", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Start an assistant message + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + // End it with aborted — sets lastAction to "aborted" + emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "partial" }], stopReason: "aborted", errorMessage: "killed" } }); + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("aborted")), `expected aborted, got: ${lines.join("\n")}`); +}); + +test("nested spawn dispose stops event processing", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + component.dispose(); + + // Emit event after dispose — should not update state or crash + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + const after = component.render(120); + + assert.ok(after.every((line: string) => !line.includes("thinking")), `unexpected post-dispose update: ${after.join("\n")}`); +}); + +test("nested spawn dispose aborts a claimed live child session", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + let abortCalls = 0; + const session = { + ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + assert.equal(state.childSessions.has("tool-call-1"), false); + assert.equal(state.liveChildSessions.has("tool-call-1"), true); + + component.dispose(); + + assert.equal(abortCalls, 1); + assert.equal(state.liveChildSessions.has("tool-call-1"), false); +}); + +test("nested spawn rapid events collapse to last state", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Start a tool execution + emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); + + // Rapid burst of updates without rendering between them + emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file1" }] } }); + emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file2" }] } }); + emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file3" }] } }); + + // Single render should reflect last state + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("file3"))); + + // End the tool and verify final state + emit({ type: "tool_execution_end", toolCallId: "tc-1", result: { content: [{ type: "text", text: "done" }] }, isError: false }); + + const finalLines = component.render(120); + assert.ok(finalLines.some((l: string) => l.includes("✓"))); +}); + +// Verifies pendingToolCallCreations accumulation: the last streamed args +// overwrite on each message_update before the first frame flush. +test("nested spawn uses the latest streamed tool-call args before first frame flush", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: true }, + theme, + createRenderContext(), + ) as any; + + // Spy on createToolComponent to capture args while preserving original behavior + let createdArgs: any; + const original = component.createToolComponent.bind(component); + component.createToolComponent = (toolName: string, toolCallId: string, args: any) => { + createdArgs = args; + return original(toolName, toolCallId, args); + }; + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + emit({ + type: "message_update", + message: { role: "assistant", content: [{ type: "toolCall", id: "tc-1", name: "inspect", arguments: { value: "old" } }] }, + }); + emit({ + type: "message_update", + message: { role: "assistant", content: [{ type: "toolCall", id: "tc-1", name: "inspect", arguments: { value: "new" } }] }, + }); + flushSpawnFrameScheduler(); + + assert.deepEqual(createdArgs, { value: "new" }); +}); + +test("nested spawn coalesces same-turn child events into one parent invalidate", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "file1" }] } }); + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "file2" }] } }); + + assert.equal(invalidateCalls, 0, "child events do not invalidate synchronously"); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "same-turn events coalesce into one invalidate"); + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("file2"))); +}); + +test("nested spawn ignores child renderer invalidations during parent rebuild", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session } = createSubscribableSession([]); + (session as any).getToolDefinition = (toolName: string) => toolName === "reentrant" + ? { + name: "reentrant", + renderCall(_args: any, _theme: any, context: any) { + if (!context.state.didInvalidate) { + context.state.didInvalidate = true; + context.invalidate(); + } + return { render: () => ["reentrant"] }; + }, + } + : undefined; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 0, "initial empty attach does not invalidate"); + + (session as any).messages = [{ + role: "assistant", + content: [{ type: "toolCall", id: "tc-1", name: "reentrant", arguments: {} }], + }]; + component.invalidate(); + flushSpawnFrameScheduler(); + + assert.equal(invalidateCalls, 0, "child renderer invalidate requests stay inside spawn rebuild"); +}); + +test("nested spawn shared scheduler calls each distinct invalidate once per frame", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const first = createSubscribableSession([]); + const second = createSubscribableSession([]); + state.childSessions.set("tool-call-1", first.session); + state.liveChildSessions.set("tool-call-1", first.session); + state.childSessions.set("tool-call-2", second.session); + state.liveChildSessions.set("tool-call-2", second.session); + let firstInvalidates = 0; + let secondInvalidates = 0; + + const firstComponent = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ toolCallId: "tool-call-1", invalidate: () => { firstInvalidates++; } }), + ) as any; + const secondComponent = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ toolCallId: "tool-call-2", invalidate: () => { secondInvalidates++; } }), + ) as any; + + first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + first.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "first latest" }] } }); + second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "second latest" }] } }); + + assert.equal(firstInvalidates, 0, "shared scheduler defers parent invalidate"); + assert.equal(secondInvalidates, 0, "shared scheduler defers parent invalidate"); + flushSpawnFrameScheduler(); + assert.equal(firstInvalidates, 1); + assert.equal(secondInvalidates, 1); + + assert.ok(firstComponent.render(120).some((l: string) => l.includes("first latest"))); + assert.ok(secondComponent.render(120).some((l: string) => l.includes("second latest"))); +}); + +test("nested spawn shared scheduler still coalesces duplicate invalidate callbacks", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const first = createSubscribableSession([]); + const second = createSubscribableSession([]); + state.childSessions.set("tool-call-1", first.session); + state.liveChildSessions.set("tool-call-1", first.session); + state.childSessions.set("tool-call-2", second.session); + state.liveChildSessions.set("tool-call-2", second.session); + let invalidateCalls = 0; + const invalidate = () => { invalidateCalls++; }; + + childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ toolCallId: "tool-call-1", invalidate }), + ); + childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ toolCallId: "tool-call-2", invalidate }), + ); + + first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "identical callbacks still coalesce"); +}); + +test("nested spawn renders state changes across frame boundaries", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // First batch: message_start sets thinking state, flush triggers render + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + const firstLines = component.render(120); + assert.ok(firstLines.some((l: string) => l.includes("thinking"))); + + // Second batch: message_update with new text, flush triggers new render + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "batch 2" }] } }); + flushSpawnFrameScheduler(); + const secondLines = component.render(120); + assert.ok(secondLines.some((l: string) => l.includes("batch 2"))); +}); + +test("nested spawn dispose cancels pending and further invalidates after cleanup", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + assert.equal(invalidateCalls, 0, "event does not invalidate synchronously"); + + component.dispose(); + flushSpawnFrameScheduler(); + + // After dispose, emitting more events does not call invalidate + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "after" }] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 0, "dispose cancels pending and future invalidates"); + + // Render still works after dispose without crashing + const lines = component.render(120); + assert.ok(lines.length > 0, "render after dispose should not crash"); +}); + +test("nested spawn recovers batching state after event handler error", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Bad event triggers an error in handleMessageStart (null message) + // catch block must call resetRenderBatching() so the flag resets + emit({ type: "message_start", message: null } as any); + + // Good event after error — should still schedule and render + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("thinking")), + "error recovery should allow subsequent events to render"); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[0]), /Event handler error/); +}); + +test("handleEvent gracefully degrades with null message events", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // asToolResult is exercised indirectly through tool_execution_update + // with null partialResult — the runtime guard should handle it without crashing + emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); + emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: null }); + emit({ type: "tool_execution_end", toolCallId: "tc-1", result: null, isError: false }); + + // No crash = asToolResult guard works + const lines = component.render(120); + assert.ok(Array.isArray(lines)); +}); \ No newline at end of file diff --git a/tests/unit/spawn-lifecycle.test.ts b/tests/unit/spawn-lifecycle.test.ts new file mode 100644 index 0000000..611e3d5 --- /dev/null +++ b/tests/unit/spawn-lifecycle.test.ts @@ -0,0 +1,315 @@ +import test, { afterEach, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createState, resetState } from "../../state.js"; +import { createSession, createSubscribableSession, createTestPI, createRenderContext, theme } from "./helpers.js"; +import { createTestHarness, type TestHarness } from "../test-utils.js"; +import { registerSpawnTool } from "../../spawn/index.js"; +import { flushSpawnFrameScheduler } from "../../spawn/renderer.js"; + +let h: TestHarness; + +function makeChildSpawnTool(state: any) { + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + return pi.tools.get("spawn"); +} + +beforeEach(() => { + h = createTestHarness(); +}); + +afterEach(() => { + h.teardown(); +}); + +test("resetState aborts and clears child session registries", () => { + const state = createState(); + let abortCalls = 0; + const session = { + ...createSession([]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + resetState(state); + assert.equal(abortCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("resetState aborts a claimed child session after render ownership transfer", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + let abortCalls = 0; + const session = { + ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ); + + assert.equal(state.childSessions.has("tool-call-1"), false); + assert.equal(state.liveChildSessions.has("tool-call-1"), true); + + resetState(state); + + assert.equal(abortCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("nested spawn drops events after resetState bumps child epoch", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + resetState(state); + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + + const after = component.render(120); + assert.equal(invalidateCalls, 0, "stale events should not request rerender after reset"); + assert.deepEqual(after, before, "stale events should not change rendered state after reset"); +}); + +test("nested spawn drops events when session is replaced in live state", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + const replacementSession = createSubscribableSession([]).session; + state.liveChildSessions.set("tool-call-1", replacementSession); + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + + const after = component.render(120); + assert.equal(invalidateCalls, 0, "replaced sessions should not request rerender"); + assert.deepEqual(after, before, "replaced sessions should not change rendered state"); +}); + +test("nested spawn completed-session deletion stays stale even if the toolCallId is later reused", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + state.liveChildSessions.delete("tool-call-1"); + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + const afterDeletion = component.render(120); + assert.equal(invalidateCalls, 0, "completed-session deletion should immediately stale the old session"); + assert.deepEqual(afterDeletion, before, "completed-session deletion should freeze the rendered state before reuse"); + + state.liveChildSessions.set("tool-call-1", createSubscribableSession([]).session); + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "should be dropped" }] } }); + const afterReuse = component.render(120); + assert.equal(invalidateCalls, 0, "toolCallId reuse should not revive a completed stale session"); + assert.deepEqual(afterReuse, before, "toolCallId reuse should keep the old rendered state frozen"); + assert.ok(afterReuse.every((l: string) => !l.includes("should be dropped")), "toolCallId reuse should not admit stale text updates"); +}); + +test("nested spawn drops late events after live registry deletion", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + state.liveChildSessions.delete("tool-call-1"); + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + + const after = component.render(120); + assert.equal(invalidateCalls, 0, "completed-session deletion should stop rerenders from late events"); + assert.deepEqual(after, before, "completed-session deletion should freeze the rendered state"); +}); + +test("nested spawn processes stale-state events without invalidating the parent", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + // Emit a message_start while the session is still fresh — triggers a render after flush + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "fresh-session event triggers invalidate"); + + // Now mark the session stale + state.liveChildSessions.delete("tool-call-1"); + + // Subsequent events are dropped by handleEvent's isStaleSession check + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "stale" }] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "stale-session events do not invalidate"); + + // The optimistic event state was applied (message_start set thinking), + // but stale-session updates are dropped — the component shows the last + // known state before staleness, not a rolled-back version. + const after = component.render(120); + assert.ok(after.some((l: string) => l.includes("thinking")), + "optimistic event state from when session was still fresh is visible"); + assert.ok(!after.some((l: string) => l.includes("stale")), + "stale-session events are dropped"); +}); + +test("nested spawn cancels a queued parent invalidate when the session becomes stale before flush", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + state.liveChildSessions.delete("tool-call-1"); + flushSpawnFrameScheduler(); + + assert.equal(invalidateCalls, 0, "stale-before-flush sessions cancel queued parent invalidates"); + assert.deepEqual(component.render(120), before, "stale-before-flush sessions roll back optimistic event state"); +}); + +test("nested spawn reattach resets render guard for the new session", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const first = createSubscribableSession([]); + state.childSessions.set("tool-call-1", first.session); + state.liveChildSessions.set("tool-call-1", first.session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + + first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "first session event triggers invalidate after scheduler flush"); + + // Reattach resets the render guard + const second = createSubscribableSession([{ role: "assistant", content: [{ type: "text", text: "replacement" }] }]); + state.childSessions.set("tool-call-1", second.session); + state.liveChildSessions.set("tool-call-1", second.session); + const sameComponent = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ lastComponent: component, invalidate: () => { invalidateCalls++; } }), + ) as any; + + second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "replacement 2" }] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 2, "second session event triggers another invalidate after reattach"); + const lines = sameComponent.render(120); + assert.ok(lines.some((l: string) => l.includes("replacement 2"))); +}); + +test("nested spawn dispose then reattach streams new session events", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const first = createSubscribableSession([]); + state.childSessions.set("tool-call-1", first.session); + state.liveChildSessions.set("tool-call-1", first.session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "first" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + component.dispose(); + + // Attach a second session to the same toolCallId after dispose + const second = createSubscribableSession([ + { role: "assistant", content: [{ type: "text", text: "second" }] }, + ]); + state.childSessions.set("tool-call-1", second.session); + state.liveChildSessions.set("tool-call-1", second.session); + const reattached = childSpawnTool.renderResult( + { content: [{ type: "text", text: "second" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ lastComponent: component }), + ) as any; + + second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "session B output" }] } }); + flushSpawnFrameScheduler(); + + const lines = reattached.render(120); + assert.ok(lines.some((l: string) => l.includes("session B output")), + "reattached component should render events from the new session"); + assert.equal(lines.some((l: string) => l.includes("first")), false, + "reattached component should not show stale content from disposed session"); +}); \ No newline at end of file diff --git a/tests/unit/spawn-render.test.ts b/tests/unit/spawn-render.test.ts new file mode 100644 index 0000000..c4c4eba --- /dev/null +++ b/tests/unit/spawn-render.test.ts @@ -0,0 +1,298 @@ +/** + * Render-focused tests for the spawn module. + * + * Extracted from spawn.test.ts to keep focused suites. These tests + * verify visual rendering of spawn results — collapsed/expanded + * output, theme application, truncation display, and render caching. + * + * Execution and lifecycle tests remain in spawn.test.ts. + */ + +import test, { afterEach, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { createState } from "../../state.js"; +import { registerSpawnTool } from "../../spawn/index.js"; +import { renderSpawnResult } from "../../spawn/renderer.js"; +import { + createTestPI, + theme, + ansiTheme, + createRenderContext, + createSession, + stripAnsi, + getRenderedLine, + getLineContaining, + assertShellBackgroundPreserved, +} from "./helpers.js"; +import { createTestHarness, type TestHarness } from "../test-utils.js"; + +let h: TestHarness; + +function makeChildSpawnTool(state: any) { + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + return pi.tools.get("spawn"); +} + +beforeEach(() => { + h = createTestHarness(); +}); + +afterEach(() => { + h.teardown(); +}); + +test("collapsed nested spawn render shows preview and stats", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix\nseven" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { + model: "mock-model", + thinking: "medium", + truncated: true, + stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, + }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("mock-model • medium"))); + assert.ok(lines.some((l: string) => l.includes("one"))); + assert.ok(lines.some((l: string) => l.includes("five"))); + assert.ok(lines.some((l: string) => l.includes("... 2 more lines"))); + assert.ok(lines.some((l: string) => l.includes("tok 12/34"))); + assert.ok(lines.some((l: string) => l.includes("trunc"))); +}); + +test("collapsed nested spawn render keeps all text blocks from the last assistant message", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "first" }, { type: "text", text: "second" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("first"))); + assert.ok(lines.some((l: string) => l.includes("second"))); +}); + +test("collapsed nested spawn truncation preserves shell background across preview and stats lines", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "Research the nudge on toggle off TODO from the readonly mode plan." }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { + model: "mock-model", + thinking: "medium", + truncated: true, + stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, + }, + }, + { expanded: false }, + ansiTheme, + createRenderContext(), + ) as any; + + const lines = component.render(24); + const previewLine = getRenderedLine(lines, plain => plain.includes("Research")); + const statsLine = getRenderedLine(lines, plain => plain.includes("tok 12/34")); + assertShellBackgroundPreserved(previewLine); + assertShellBackgroundPreserved(statsLine); + assert.match(stripAnsi(statsLine), /tok 12\/34/); +}); + +test("collapsed nested spawn keeps truncated stats line calm", () => { + const markerTheme = { + fg: (name: string, text: string) => `<${name}>${text}`, + bg: (_name: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme; + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "short preview" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { + model: "mock-model", + thinking: "medium", + truncated: true, + stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, + }, + }, + { expanded: false }, + markerTheme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + const statsLine = getLineContaining(lines, "tok 12/34"); + assert.match(statsLine, /.*tok 12\/34.*trunc.*<\/dim>/); + assert.equal(statsLine.includes(""), false); +}); + +test("nested spawn render is safe without details", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }] }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("hello"))); +}); + +test("expanded nested spawn header stays within width after indent", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "model-name", thinking: "medium", truncated: false }, + }, + { expanded: true }, + theme, + createRenderContext({ expanded: true }), + ) as any; + + const lines = component.render(24); + const headerLine = lines.find((line: string) => line.includes("model-name")) ?? ""; + assert.ok(headerLine.startsWith(" ")); + assert.ok(stripAnsi(headerLine).length <= 24); +}); + +test("nested spawn render cache preserves stable output for identical params", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const first = component.render(120); + const second = component.render(120); + assert.deepEqual(second, first); +}); + +test("nested spawn clears cached render when showImages changes", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }, { type: "image", data: "iVBOR", mimeType: "image/png" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false }, + }, + { expanded: true }, + theme, + createRenderContext({ expanded: true, showImages: true }), + ) as any; + const linesWithImages = component.render(120); + + const sameComponent = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false }, + }, + { expanded: true }, + theme, + createRenderContext({ expanded: true, showImages: false, lastComponent: component }), + ) as any; + const linesWithoutImages = sameComponent.render(120); + + assert.equal(sameComponent, component); + assert.ok(Array.isArray(linesWithImages)); + assert.ok(Array.isArray(linesWithoutImages)); + assert.equal((sameComponent as any).cachedShowImages, false); +}); + +test("nested spawn rerenders when stats become unavailable", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + const before = component.render(120); + assert.equal(before.some((l: string) => l.includes("stats unavailable")), false); + + const sameComponent = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false, outcome: "success", statsUnavailable: true }, + }, + { expanded: false }, + theme, + createRenderContext({ lastComponent: component }), + ) as any; + const after = sameComponent.render(120); + + assert.equal(sameComponent, component); + assert.ok(after.some((l: string) => l.includes("stats unavailable"))); + assert.equal(after.some((l: string) => l.includes("initializing")), false); +}); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts new file mode 100644 index 0000000..817b3d4 --- /dev/null +++ b/tests/unit/spawn.test.ts @@ -0,0 +1,1460 @@ +import test, { afterEach, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { createState, resetState } from "../../state.js"; +import { + buildChildToolNames, + createChildTools, + executeSpawn, + registerSpawnTool, +} from "../../spawn/index.js"; +import { renderSpawnResult } from "../../spawn/renderer.js"; +import { createTestPI, createRenderContext, createSession, createSubscribableSession, messageText, makeTUICtx, theme, createTestAssistantMessage, createTestAssistantStream } from "./helpers.js"; +import { createTestHarness, type TestHarness } from "../test-utils.js"; + +let h: TestHarness; + +function makeChildSpawnTool(state: any) { + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + return pi.tools.get("spawn"); +} + +beforeEach(() => { + h = createTestHarness(); +}); + +afterEach(() => { + h.teardown(); +}); + +test("agentic e2e spawn child can use active registered non-builtin tool", async () => { + const tempRoot = await mkdtemp(join(tmpdir(), "pi-agenticoding-a10-")); + const tempCwd = join(tempRoot, "project"); + const tempAgentDir = join(tempRoot, "agent"); + const extensionDir = join(tempCwd, ".pi", "extensions"); + const sentinel = "AGENTIC_E2E_PROBE_OK"; + const oldAgentDir = process.env.PI_CODING_AGENT_DIR; + const oldOpenAiApiKey = process.env.OPENAI_API_KEY; + const parentRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + let streamCallCount = 0; + + try { + await mkdir(extensionDir, { recursive: true }); + await mkdir(tempAgentDir, { recursive: true }); + await writeFile(join(tempCwd, "package.json"), JSON.stringify({ type: "module" })); + await writeFile( + join(extensionDir, "agentic-e2e-probe.js"), + ` +export default function(pi) { + pi.registerTool({ + name: "agentic_e2e_probe", + label: "Agentic E2E Probe", + description: "Return the deterministic Story 04 A10 sentinel.", + promptSnippet: "Call agentic_e2e_probe to return the Story 04 A10 sentinel.", + parameters: { type: "object", properties: {}, additionalProperties: false }, + async execute() { + globalThis.__agenticE2eProbeCalls = (globalThis.__agenticE2eProbeCalls ?? 0) + 1; + return { + content: [{ type: "text", text: "${sentinel}" }], + details: { sentinel: "${sentinel}" }, + }; + }, + }); +} +`, + ); + + process.env.PI_CODING_AGENT_DIR = tempAgentDir; + process.env.OPENAI_API_KEY = "test-openai-key"; + (globalThis as any).__agenticE2eProbeCalls = 0; + + parentRegistry.registerProvider("openai", { + name: "Agentic E2E OpenAI-compatible provider", + api: "agentic-e2e-api", + apiKey: "test-openai-key", + baseUrl: "http://localhost:0", + streamSimple: (model: any, context: any) => { + streamCallCount += 1; + if (streamCallCount === 1) { + const promptText = context.messages.map(messageText).join("\n"); + assert.match(promptText, /agentic_e2e_probe/); + assert.match(promptText, new RegExp(sentinel)); + return createTestAssistantStream(createTestAssistantMessage(model, [ + { type: "toolCall", id: "probe-call-1", name: "agentic_e2e_probe", arguments: {} }, + ], "tool_calls")); + } + + const probeResult = context.messages.find((message: any) => + message.role === "toolResult" && + message.toolName === "agentic_e2e_probe" && + messageText(message).includes(sentinel) + ); + const text = probeResult ? sentinel : "AGENTIC_E2E_PROBE_MISSING"; + return createTestAssistantStream(createTestAssistantMessage(model, [{ type: "text", text }])); + }, + models: [{ + id: "agentic-e2e-model", + name: "Agentic E2E Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 1024, + }], + }); + const model = parentRegistry.find("openai", "agentic-e2e-model"); + assert.ok(model); + + const pi = createTestPI(); + pi.setToolSource("agentic_e2e_probe", "project"); + pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]); + pi.setAllTools(["read", "agentic_e2e_probe", "spawn"]); + const state = createState(); + const childPrompt = `Use the agentic_e2e_probe tool and return ${sentinel}.`; + + registerSpawnTool(pi as any, state); + const result = await pi.tools.get("spawn").execute( + "spawn-e2e", + { prompt: childPrompt, thinking: "medium" }, + undefined, + undefined, + { model, cwd: tempCwd }, + ); + + assert.equal(result.content[0].text, sentinel); + assert.equal((globalThis as any).__agenticE2eProbeCalls, 1); + assert.equal(streamCallCount, 2); + } finally { + parentRegistry.unregisterProvider("openai"); + if (oldAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = oldAgentDir; + } + if (oldOpenAiApiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = oldOpenAiApiKey; + } + delete (globalThis as any).__agenticE2eProbeCalls; + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("spawn execute passes broad active registered tool formula to child session", async () => { + const pi = createTestPI(); + pi.setToolSource("project_search", "project"); + pi.setToolSource("inactive_registered", "extension"); + pi.setActiveTools(["read", "bash", "spawn", "handoff", "project_search", "phantom_tool"]); + pi.setAllTools(["read", "bash", "spawn", "handoff", "project_search", "inactive_registered"]); + const state = createState(); + + let seenConfig: any; + const mockFactory = async (config: any) => { + seenConfig = config; + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task", thinking: "high" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + assert.equal(seenConfig.model.id, "mock-model"); + assert.equal(seenConfig.thinkingLevel, "high"); + assert.equal(seenConfig.cwd, "/tmp"); + assert.deepEqual( + new Set(seenConfig.tools), + new Set(["read", "bash", "project_search", "notebook_write", "notebook_read", "notebook_index"]), + ); + assert.deepEqual(seenConfig.customTools.map((tool: any) => tool.name), ["notebook_write", "notebook_read", "notebook_index"]); +}); + +test("spawn execute builds prompt with notebook pages and task", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + state.notebookPages.set("entry-a", "preview line\nfull body"); + + let seenPrompt = ""; + const mockFactory = async (config: any) => { + const session = { + messages: [] as any[], + prompt: async (prompt: string) => { + seenPrompt = prompt; + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + // Verify user-facing invariants: task text is included, notebook pages are referenced + assert.match(seenPrompt, /Do the task/); + assert.match(seenPrompt, /entry-a: preview line/); +}); + +test("spawn renderResult falls back to static text when no live session is stored", () => { + const state = createState(); + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + + const result = pi.tools.get("spawn").renderResult( + { + content: [{ type: "text", text: "fallback output" }], + details: { model: "m", thinking: "low", truncated: false }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const lines = result.render(120); + assert.ok(lines.some((l: string) => l.includes("m • low"))); + assert.ok(lines.some((l: string) => l.includes("fallback output"))); +}); + +test("spawn renderResult distinguishes aborted and error outcomes", () => { + const state = createState(); + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + + const aborted = pi.tools.get("spawn").renderResult( + { + content: [{ type: "text", text: "stopped" }], + details: { model: "m", thinking: "low", truncated: false, outcome: "aborted" }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + const error = pi.tools.get("spawn").renderResult( + { + content: [{ type: "text", text: "failed" }], + details: { model: "m", thinking: "low", truncated: false, outcome: "error" }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const abortedLines = aborted.render(120); + const errorLines = error.render(120); + assert.ok(abortedLines.some((l: string) => l.includes("✗ m • low"))); + assert.ok(abortedLines.some((l: string) => l.includes("aborted"))); + assert.ok(errorLines.some((l: string) => l.includes("⚠ m • low"))); + assert.ok(errorLines.some((l: string) => l.includes("error"))); +}); + +test("spawn execute returns result and stats", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + const updates: any[] = []; + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => ({ + tokens: { input: 11, output: 22, cacheRead: 3, cacheWrite: 4, total: 40 }, + cost: 0.5, + assistantMessages: 2, + }), + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + const result = await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task", thinking: "high" }, + undefined, + (update: any) => updates.push(update), + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + assert.deepEqual(updates, [{ + content: [], + details: { model: "mock-model", thinking: "high", truncated: false, outcome: "running" }, + }]); + assert.equal(result.content[0].text, "child result"); + assert.equal(result.details.outcome, "success"); + assert.deepEqual(result.details.stats, { + inputTokens: 11, + outputTokens: 22, + cacheReadTokens: 3, + cacheWriteTokens: 4, + totalTokens: 40, + cost: 0.5, + turns: 2, + }); +}); + +test("spawn execute marks stats unavailable when stats collection throws", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => { + throw new Error("stats failed"); + }, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + const result = await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + assert.equal(result.details.stats, undefined); + assert.equal(result.details.statsUnavailable, true); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[1]), /stats failed/); + assert.equal(h.warnings[0].args[2], "spawn-1"); +}); + +test("spawn execute throws when child produces no output", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => {}, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + await assert.rejects( + () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }), + /Child agent produced no output\./, + ); +}); + +test("spawn execute clears childSessions when prompt throws", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + throw new Error("prompt failed"); + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + await assert.rejects( + () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }), + /prompt failed/, + ); + assert.equal(state.childSessions.size, 0); +}); + +test("spawn execute clears childSessions after successful completion when unrendered", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + const result = await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + assert.equal(result.content[0].text, "child result"); + assert.equal(state.childSessions.size, 0); +}); + +test("spawn execute fails explicitly without a configured model", async () => { + const pi = createTestPI(); + const state = createState(); + registerSpawnTool(pi as any, state); + await assert.rejects( + () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { cwd: "/tmp" }), + /No model configured\. Cannot spawn child agent\./, + ); +}); + +test("child tool names inherit active registered builtins and exclude recursive controls", () => { + const state = createState(); + const childTools = createChildTools(createTestPI() as any, state); + assert.equal(childTools.some(t => t.name === "spawn"), false); + const childToolNames = buildChildToolNames( + ["read", "bash", "spawn", "handoff", "future_tool"], + childTools, + [ + { name: "read", sourceInfo: { source: "builtin" } }, + { name: "bash", sourceInfo: { source: "builtin" } }, + { name: "spawn", sourceInfo: { source: "builtin" } }, + { name: "handoff", sourceInfo: { source: "builtin" } }, + { name: "future_tool", sourceInfo: { source: "project" } }, + ] as any, + ); + assert.equal(childToolNames.includes("read"), true); + assert.equal(childToolNames.includes("bash"), true); + assert.equal(childToolNames.includes("spawn"), false); + assert.equal(childToolNames.includes("handoff"), false); +}); + +test("spawn renderResult transfers session ownership out of shared state", () => { + const state = createState(); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + + const component = pi.tools.get("spawn").renderResult( + { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + assert.equal(state.childSessions.has("tool-call-1"), false); + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("hello"))); +}); + +test("spawn renderResult reuses lastComponent", () => { + const state = createState(); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + + const first = pi.tools.get("spawn").renderResult( + { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ); + const second = pi.tools.get("spawn").renderResult( + { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ lastComponent: first }), + ); + assert.equal(first, second); +}); + +test("resetState aborts and clears child session registries", () => { + const state = createState(); + let abortCalls = 0; + const session = { + ...createSession([]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + resetState(state); + assert.equal(abortCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("resetState aborts a claimed child session after render ownership transfer", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + let abortCalls = 0; + const session = { + ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ); + + assert.equal(state.childSessions.has("tool-call-1"), false); + assert.equal(state.liveChildSessions.has("tool-call-1"), true); + + resetState(state); + + assert.equal(abortCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("executeSpawn suppresses stale child sessions after resetState during async setup", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + let resolveFactory!: (value: any) => void; + const factoryReady = new Promise((resolve) => { + resolveFactory = resolve; + }); + let promptCalled = false; + let abortCalls = 0; + let onUpdateCalled = false; + const staleSession = { + messages: [] as any[], + prompt: async () => { + promptCalled = true; + staleSession.messages = [{ role: "assistant", content: [{ type: "text", text: "stale result" }] }]; + }, + abort: async () => { + abortCalls++; + }, + getSessionStats: () => undefined, + }; + + const executePromise = executeSpawn( + "spawn-1", + pi as any, + { model: { id: "mock-model" }, cwd: "/tmp" } as any, + state, + { prompt: "Do the task" }, + undefined, + () => { + onUpdateCalled = true; + }, + "medium", + async () => factoryReady, + ); + + resetState(state); + const freshSession = createSession([{ role: "assistant", content: [{ type: "text", text: "fresh result" }] }]); + state.childSessions.set("spawn-1", freshSession); + state.liveChildSessions.set("spawn-1", freshSession); + resolveFactory({ session: staleSession as any }); + + await assert.rejects(() => executePromise, /invalidated by reset/i); + assert.equal(onUpdateCalled, false); + assert.equal(promptCalled, false); + assert.equal(abortCalls, 1); + assert.equal(state.childSessions.get("spawn-1"), freshSession); + assert.equal(state.liveChildSessions.get("spawn-1"), freshSession); +}); + +test("child tool names inherit active registered MCP extension tools", () => { + const state = createState(); + const childTools = createChildTools(createTestPI() as any, state); + + const toolNames = buildChildToolNames( + ["read", "chunkhound_code_research", "mcp_status"], + childTools, + [ + { name: "read", sourceInfo: { source: "builtin" } }, + { name: "chunkhound_code_research", sourceInfo: { source: "extension" } }, + { name: "mcp_status", sourceInfo: { source: "extension" } }, + ] as any, + ); + + assert.equal(toolNames.includes("chunkhound_code_research"), true); + assert.equal(toolNames.includes("mcp_status"), true); +}); + +test("child tool names inherit active registered project package and local extension tools", () => { + const state = createState(); + const childTools = createChildTools(createTestPI() as any, state); + + const toolNames = buildChildToolNames( + ["project_search", "package_lint", "local_helper"], + childTools, + [ + { name: "project_search", sourceInfo: { source: "project" } }, + { name: "package_lint", sourceInfo: { source: "package" } }, + { name: "local_helper", sourceInfo: { source: "local" } }, + ] as any, + ); + + assert.equal(toolNames.includes("project_search"), true); + assert.equal(toolNames.includes("package_lint"), true); + assert.equal(toolNames.includes("local_helper"), true); +}); + +test("child tool names exclude inactive registered and active phantom tools", () => { + const state = createState(); + const childTools = createChildTools(createTestPI() as any, state); + + const toolNames = buildChildToolNames( + ["read", "active_phantom"], + childTools, + [ + { name: "read", sourceInfo: { source: "builtin" } }, + { name: "inactive_registered", sourceInfo: { source: "extension" } }, + ] as any, + ); + + assert.equal(toolNames.includes("read"), true); + assert.equal(toolNames.includes("inactive_registered"), false); + assert.equal(toolNames.includes("active_phantom"), false); + assert.ok(toolNames.includes("notebook_write")); + assert.ok(toolNames.includes("notebook_read")); + assert.ok(toolNames.includes("notebook_index")); + assert.equal(toolNames.includes("handoff"), false); + assert.equal(toolNames.includes("spawn"), false); +}); + +test("spawn execute short-circuits when signal is already aborted", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + let abortCalled = false; + let promptCalled = false; + let onUpdateCalled = false; + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + promptCalled = true; + }, + abort: async () => { abortCalled = true; }, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + const controller = new AbortController(); + controller.abort(); + + await assert.rejects( + () => pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task" }, + controller.signal, + () => { onUpdateCalled = true; }, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ), + /abort/i, + ); + + assert.equal(abortCalled, true); + assert.equal(promptCalled, false); + assert.equal(onUpdateCalled, false); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("spawn execute truncates very long child output", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + // Generate > 2000 lines of output + const longText = Array.from({ length: 2100 }, (_, i) => `Line ${i + 1}`).join("\n"); + + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: longText }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + const result = await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Generate lots of output" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + assert.equal(result.details.truncated, true); + assert.ok(result.content[0].text.includes("[Result truncated")); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("spawn execute truncates child output by byte limit", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const longText = "🙂".repeat(20_000); + + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: longText }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + const result = await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Generate byte-heavy output" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + assert.equal(result.details.truncated, true); + assert.ok(result.content[0].text.includes("[Result truncated")); + assert.ok(result.content[0].text.length < longText.length); + assert.ok(result.content[0].text.includes("\n")); +}); + +test("spawn execute tells children when no notebook pages exist", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + let promptText = ""; + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async (text: string) => { + promptText = text; + session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + assert.match(promptText, /No notebook pages\./); + assert.doesNotMatch(promptText, /Available notebook pages:/); + assert.match(promptText, /store only durable grounding knowledge for future contexts/i); + assert.match(promptText, /Keep transient task state in your final reply to the parent\./); +}); + +test("executeSpawn → onUpdate → renderResult chains session ownership", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + let onUpdateCalled = false; + let renderComponent: any = null; + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + const executePromise = pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task" }, + undefined, + (update: any) => { + onUpdateCalled = true; + // Simulate pi rendering during execution by calling renderResult + // with the same toolCallId the execute call is using. + renderComponent = pi.tools.get("spawn").renderResult( + { content: [], details: update.details }, + { expanded: false }, + theme, + { toolCallId: "spawn-1", expanded: false, showImages: true, lastComponent: undefined, invalidate: () => {} }, + ); + }, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + const result = await executePromise; + + // onUpdate was called + assert.equal(onUpdateCalled, true); + + // renderComponent from onUpdate has a live session attached + assert.equal(typeof renderComponent.hasSession, "function"); + assert.equal(renderComponent.hasSession(), true); + + // Session ownership was transferred out of the render handoff queue + assert.equal(state.childSessions.has("spawn-1"), false); + assert.equal(state.liveChildSessions.has("spawn-1"), false); + + // Component renders session content + const lines = renderComponent.render(120); + const text = lines.join(" "); + assert.ok(text.includes("result"), `expected result in render, got: ${text}`); + + // Final execute result is also correct + assert.equal(result.content[0].text, "result"); +}); + +test("spawn render shows success state when stats are unavailable", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "final summary" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false, outcome: "success", statsUnavailable: true }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("✅ mock-model • medium"))); + assert.ok(lines.some((l: string) => l.includes("stats unavailable"))); + assert.equal(lines.some((l: string) => l.includes("initializing")), false); +}); + +test("spawn execute aborts child session when signal fires during execution", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + let abortCalled = false; + let resolvePrompt!: () => void; + let promptStarted!: () => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + promptStarted(); + await new Promise((resolve) => { resolvePrompt = resolve; }); + session.messages = [{ role: "assistant", content: [{ type: "text", text: "aborted mid-flight" }] }]; + }, + abort: async () => { + abortCalled = true; + resolvePrompt(); + }, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + const controller = new AbortController(); + const executePromise = pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task" }, + controller.signal, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + await started; + controller.abort(); + + const result = await executePromise; + assert.equal(abortCalled, true); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); + assert.equal(result.content[0].text, "aborted mid-flight"); + assert.equal(result.details.outcome, "aborted"); +}); + +test("spawn renderCall shows prompt preview and thinking level", () => { + const state = createState(); + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + + const tool = pi.tools.get("spawn"); + + // Collapsed: short prompt + const collapsed = tool.renderCall({ prompt: "Do X" }, theme, { expanded: false }); + const collapsedLines = collapsed.render(120); + assert.ok(collapsedLines.some((l: string) => l.includes("spawn"))); + assert.ok(collapsedLines.some((l: string) => l.includes("Do X"))); + + // Collapsed: long prompt shows truncation hint + const longPrompt = Array.from({ length: 10 }, (_, i) => `Line ${i}`).join("\n"); + const truncated = tool.renderCall({ prompt: longPrompt }, theme, { expanded: false }); + const truncatedLines = truncated.render(120); + assert.ok(truncatedLines.some((l: string) => l.includes("more lines"))); + + // With thinking level + const withThinking = tool.renderCall({ prompt: "Do X", thinking: "high" }, theme, { expanded: false }); + const thinkingLines = withThinking.render(120); + assert.ok(thinkingLines.some((l: string) => l.includes("high"))); + + // Expanded: shows full prompt + const expanded = tool.renderCall({ prompt: longPrompt }, theme, { expanded: true }); + const expandedLines = expanded.render(120); + assert.ok(!expandedLines.some((l: string) => l.includes("more lines"))); +}); + +test("nested spawn invalidate rebuilds from the attached session transcript", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "before" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const firstRender = component.render(120); + assert.ok(firstRender.some((l: string) => l.includes("before"))); + + (session.messages[0] as any).content[0].text = "after"; + component.invalidate(); + + const secondRender = component.render(120); + assert.notEqual(firstRender, secondRender); + assert.ok(secondRender.some((l: string) => l.includes("after"))); + assert.equal(secondRender.some((l: string) => l.includes("before")), false); +}); + +test("nested spawn attachSession rebuilds after appended session messages", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + state.childSessions.set("tool-call-1", createSession([ + { role: "assistant", content: [{ type: "text", text: "before" }] }, + ])); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const firstRender = component.render(120); + assert.ok(firstRender.some((l: string) => l.includes("before"))); + + state.childSessions.set("tool-call-1", createSession([ + { role: "assistant", content: [{ type: "text", text: "before" }] }, + { role: "assistant", content: [{ type: "text", text: "after" }] }, + ])); + const sameComponent = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ lastComponent: component }), + ) as any; + + const secondRender = sameComponent.render(120); + assert.notEqual(firstRender, secondRender); + assert.ok(secondRender.some((l: string) => l.includes("after"))); +}); + +test("nested spawn attachSession rebuilds after replacing session transcript structure", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + state.childSessions.set("tool-call-1", createSession([ + { role: "assistant", content: [{ type: "text", text: "before" }] }, + ])); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const firstRender = component.render(120); + assert.ok(firstRender.some((l: string) => l.includes("before"))); + + state.childSessions.set("tool-call-1", createSession([ + { role: "user", content: [{ type: "text", text: "new task" }] }, + { role: "assistant", content: [{ type: "text", text: "replacement" }] }, + ])); + const sameComponent = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ lastComponent: component }), + ) as any; + + const secondRender = sameComponent.render(120); + assert.notEqual(firstRender, secondRender); + assert.ok(secondRender.some((l: string) => l.includes("replacement"))); + assert.equal(secondRender.some((l: string) => l.includes("before")), false); +}); + +test("nested spawn rebuildFromSession quietly tolerates missing tool definitions", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = { + messages: [{ + role: "assistant", + content: [{ type: "toolCall", name: "bash", id: "tc-1", arguments: { command: "ls" } }], + stopReason: "error", + errorMessage: "boom", + }], + subscribe: () => () => {}, + getToolDefinition: () => { throw new Error("missing tool definition"); }, + sessionManager: { getCwd: () => process.cwd() }, + abort: async () => {}, + } as any; + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false, outcome: "error" } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("⚠ m • low"))); + assert.ok(lines.some((l: string) => l.includes("error"))); + assert.equal(state.childSessions.has("tool-call-1"), false); + assert.equal(h.warnings.length, 0); +}); + +test("nested spawn attachSession recovers from subscribe throwing", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + + // Session whose subscribe() throws + const throwingSession = { + messages: [{ role: "assistant", content: [{ type: "text", text: "hello" }] }], + subscribe: () => { throw new Error("subscribe failed"); }, + getToolDefinition: () => undefined, + sessionManager: { getCwd: () => process.cwd() }, + abort: async () => {}, + } as any; + state.childSessions.set("tool-call-1", throwingSession); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Should not crash, session attached, ownership transferred + assert.equal(state.childSessions.has("tool-call-1"), false); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[0]), /Failed to subscribe/); + + // Should still render from session messages despite subscribe failure + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("hello"))); +}); + +test("concurrent spawn executions produce independent results", async () => { + const pi = createTestPI(); + const state = createState(); + + let resolveA!: () => void; + let resolveB!: () => void; + let markStartedA!: () => void; + let markStartedB!: () => void; + const gateA = new Promise((resolve) => { resolveA = resolve; }); + const gateB = new Promise((resolve) => { resolveB = resolve; }); + const startedA = new Promise((resolve) => { markStartedA = resolve; }); + const startedB = new Promise((resolve) => { markStartedB = resolve; }); + const started: string[] = []; + const outputs = new Map([ + ["task A", "result-alpha"], + ["task B", "result-beta"], + ]); + const sharedFactory = async () => { + const session = { + messages: [] as any[], + prompt: async (prompt: string) => { + const task = /## Task\n\n([\s\S]*?)\n\nWhen complete/.exec(prompt)?.[1] ?? ""; + started.push(task); + if (task === "task A") { + markStartedA(); + await gateA; + } + if (task === "task B") { + markStartedB(); + await gateB; + } + session.messages = [{ role: "assistant", content: [{ type: "text", text: outputs.get(task) ?? task }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, sharedFactory as any); + const spawnTool = pi.tools.get("spawn"); + + const resultP1 = spawnTool.execute( + "spawn-A", { prompt: "task A" }, undefined, undefined, + { model: { id: "mock" }, cwd: "/tmp" }, + ); + const resultP2 = spawnTool.execute( + "spawn-B", { prompt: "task B" }, undefined, undefined, + { model: { id: "mock" }, cwd: "/tmp" }, + ); + + await Promise.all([startedA, startedB]); + assert.deepEqual(started.sort(), ["task A", "task B"]); + resolveA(); + resolveB(); + + const [r1, r2] = await Promise.all([resultP1, resultP2]); + + assert.equal(r1.content[0].text, "result-alpha"); + assert.equal(r2.content[0].text, "result-beta"); + assert.equal(state.childSessions.has("spawn-A"), false); + assert.equal(state.childSessions.has("spawn-B"), false); +}); + +test("spawn tool definitions include prompt hints when registered", () => { + const pi = createTestPI(); + const state = createState(); + registerSpawnTool(pi as any, state); + + const spawnTool = pi.tools.get("spawn")!; + assert.ok(typeof spawnTool.promptSnippet === "string", "spawn should have promptSnippet"); + assert.ok(spawnTool.promptSnippet!.length > 10, "spawn promptSnippet should be non-trivial"); + assert.ok(Array.isArray(spawnTool.promptGuidelines), "spawn should have promptGuidelines"); + assert.ok(spawnTool.promptGuidelines!.length > 0, "spawn promptGuidelines should be non-empty"); + for (const g of spawnTool.promptGuidelines!) { + assert.ok(g.length > 10, "each spawn guideline should be non-trivial"); + } +}); + +test("executeSpawn detects stale session before session creation", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + let resolveFactory!: (value: any) => void; + const factoryReady = new Promise((resolve) => { + resolveFactory = resolve; + }); + let factoryCalled = false; + let abortCalls = 0; + + const executePromise = executeSpawn( + "spawn-1", + pi as any, + { model: { id: "mock-model" }, cwd: "/tmp" } as any, + state, + { prompt: "Do the task" }, + undefined, + undefined, + "medium", + async () => { + factoryCalled = true; + await factoryReady; + return { + session: { + messages: [] as any[], + prompt: async () => {}, + abort: async () => { abortCalls++; }, + getSessionStats: () => undefined, + } as any, + extensionsResult: undefined as any, + }; + }, + ); + + // Reset state while executeSpawn is awaiting the factory + resetState(state); + // Now allow the factory to resolve — session should be immediately stale + resolveFactory({}); + + await assert.rejects( + () => executePromise, + /invalidated by reset/i, + ); + assert.equal(factoryCalled, true); + assert.equal(abortCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("executeSpawn aborts stale child when resetState fires during prompt", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + let rejectPrompt!: (err: Error) => void; + let resolvePromptStarted!: () => void; + const promptStartedPromise = new Promise((r) => { resolvePromptStarted = r; }); + let abortCalls = 0; + + const executePromise = executeSpawn( + "spawn-1", + pi as any, + { model: { id: "mock-model" }, cwd: "/tmp" } as any, + state, + { prompt: "Do the task" }, + undefined, + undefined, + "medium", + async () => ({ + extensionsResult: undefined as any, + session: { + messages: [] as any[], + prompt: async () => { + resolvePromptStarted(); + await new Promise((_resolve, reject) => { + rejectPrompt = reject; + }); + }, + abort: async () => { + abortCalls++; + rejectPrompt?.(new Error("aborted")); + }, + getSessionStats: () => undefined, + } as any, + }), + ); + + // Wait for session to be created and prompt to start + await promptStartedPromise; + // Reset state triggers abortAndClearChildSessions which calls session.abort() + // abort() rejects the pending prompt, which causes the stale check to fire + resetState(state); + + await assert.rejects( + () => executePromise, + /invalidated by reset/i, + ); + // abort is called once by clearChildSession (identity match via liveChildSessions) + assert.equal(abortCalls >= 1, true); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("truncateText respects line limit before byte limit", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + + // Generate text with > 2000 lines to trigger line truncation + const text = Array.from({ length: 2500 }, (_, i) => `Line ${i}`).join("\n"); + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, mockFactory as any); + + const result = await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Generate lots of lines" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + + assert.equal(result.details.truncated, true); + const textLines = result.content[0].text.split("\n"); + assert.ok(textLines[0].startsWith("Line 0"), `expected first line, got: ${textLines[0]}`); + assert.ok(result.content[0].text.includes("[Result truncated")); +}); + +test("nested spawn setExpanded and setShowImages no-op when value matches", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Calling setExpanded with same value should not throw or crash + component.setExpanded(false); + component.setExpanded(true); + component.setShowImages(true); + component.setShowImages(false); + + // Component still renders + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("hello"))); +}); + +test("abortAndClearChildSessions deduplicates sessions across both maps", () => { + const state = createState(); + let abortCalls = 0; + const mockSession = { + messages: [], + abort: async () => { abortCalls++; }, + } as any; + + // Put the same session object in both maps under the same key + state.childSessions.set("tc-1", mockSession); + state.liveChildSessions.set("tc-1", mockSession); + + resetState(state); + + // Dedup via the `seen` map ensures abort is called exactly once + assert.equal(abortCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("renderSpawnResult handles result with no details field", () => { + const state = createState(); + const result = renderSpawnResult( + { content: [{ type: "text", text: "hello" }] }, + false, + theme, + { toolCallId: "tc-1", invalidate: () => {}, showImages: false }, + state, + ); + // Should return a Text component that renders without crashing + assert.ok(result, "renderSpawnResult should return a component"); + const lines = (result as any).render(120); + assert.ok(Array.isArray(lines), "render should return an array of lines"); + assert.ok(lines.some((l: string) => l.includes("hello")), `expected 'hello' in output, got: ${lines.join("\n")}`); +}); + +test("registerSpawnTool registers a tool with correct name and metadata", () => { + const pi = createTestPI(); + const state = createState(); + registerSpawnTool(pi as any, state); + + const tool = pi.tools.get("spawn"); + assert.ok(tool, "spawn tool should be registered"); + assert.equal(tool.name, "spawn"); + assert.equal(tool.label, "Spawn"); + assert.equal(typeof tool.description, "string"); + assert.match(tool.description, /active registered tools executable in the child session/); + assert.match(tool.description, /shared notebook tools/); + assert.match(tool.description, /cannot spawn or handoff/); + assert.doesNotMatch(tool.description, /supported built-in tools/); + assert.equal(typeof tool.execute, "function"); + assert.equal(typeof tool.renderCall, "function"); + assert.equal(typeof tool.renderResult, "function"); + assert.equal(tool.renderShell, "self"); + // parameters are a TypeBox schema object — just verify it exists + assert.ok(tool.parameters, "should have parameters"); + assert.equal(tool.executionMode, undefined, "spawn should not be sequential"); +}); + +test("spawn docs document active registered inheritance", async () => { + const readme = await readFile("README.md", "utf8"); + const changelog = await readFile("CHANGELOG.md", "utf8"); + const spawnSection = /### Spawn — Isolate Noise[\s\S]*?### Notebook/.exec(readme)?.[0] ?? ""; + const unreleased = /## \[Unreleased\][\s\S]*?## \[0\.3\.0\]/.exec(changelog)?.[0] ?? ""; + + assert.match(spawnSection, /active registered tools executable in the child session/); + assert.match(spawnSection, /MCP\/extension tools such as ChunkHound/); + assert.match(spawnSection, /[Cc]hild-local notebook tools/); + assert.match(spawnSection, /cannot spawn grandchildren or handoff/); + assert.doesNotMatch(spawnSection, /built-in tools only/); + assert.match(unreleased, /active registered parent tools/); + assert.match(unreleased, /spawn and handoff/); + assert.match(unreleased, /notebook tools/); +}); \ No newline at end of file diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts new file mode 100644 index 0000000..c52778a --- /dev/null +++ b/tests/unit/state-invariants.test.ts @@ -0,0 +1,360 @@ +/** + * Property-based state invariant tests using fast-check. + * + * Generates random sequences of state operations and asserts invariants + * that must hold after every operation on a pure AgenticodingState. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fc from "fast-check"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createState, resetState, abortAndClearChildSessions } from "../../state.js"; +import type { AgenticodingState } from "../../state.js"; +import { + setActiveNotebookTopic, + clearActiveNotebookTopic, +} from "../../notebook/topic.js"; +import { saveNotebookPage } from "../../notebook/store.js"; +import { createTestHarness } from "../test-utils.js"; + +// ── Mock ExtensionAPI ───────────────────────────────────────────────── + +const mockPi = { appendEntry: () => {} } as unknown as ExtensionAPI; + +// ── Action types ────────────────────────────────────────────────────── + +type StateAction = + | { type: "reset" } + | { type: "setTopic"; name: string } + | { type: "clearTopic" } + | { type: "savePage"; name: string } + | { type: "addChildSession"; id: string } + | { type: "abortChildren" }; + +/** Generator for valid normalized topic names (non-empty after normalizeNotebookTopic). */ +const arbTopicName = fc + .stringMatching(/^[a-zA-Z][a-zA-Z0-9 _-]{0,19}$/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + +/** Generator for valid notebook page names (kebab-case). */ +const arbPageName = fc.stringMatching(/^[a-z][a-z0-9-]{0,19}$/); + +/** Generator for session IDs (simulating toolCallId format). */ +const arbSessionId = fc + .stringMatching(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/) + .filter((s) => s.length > 0); + +// ── Apply ───────────────────────────────────────────────────────────── + +async function apply( + state: AgenticodingState, + action: StateAction, +): Promise { + switch (action.type) { + case "reset": + resetState(state); + break; + case "setTopic": + setActiveNotebookTopic(state, action.name, "agent"); + break; + case "clearTopic": + clearActiveNotebookTopic(state); + break; + case "savePage": + await saveNotebookPage(mockPi, state, action.name, "content-" + action.name); + break; + case "addChildSession": + state.childSessions.set(action.id, { abort: () => Promise.resolve() } as any); + state.liveChildSessions.set(action.id, { abort: () => Promise.resolve() } as any); + break; + case "abortChildren": + abortAndClearChildSessions(state); + break; + } +} + +// ── Invariant helpers ───────────────────────────────────────────────── + +function assertTopicSourceCoupling(state: AgenticodingState): void { + const msg = `topic=${state.activeNotebookTopic} source=${state.activeNotebookTopicSource}`; + if (state.activeNotebookTopic === null) { + assert.equal(state.activeNotebookTopicSource, null, `topic null → source null: ${msg}`); + } else { + assert.notEqual(state.activeNotebookTopicSource, null, `topic set → source set: ${msg}`); + } + // Bidirectional: source null → topic null too + if (state.activeNotebookTopicSource === null) { + assert.equal(state.activeNotebookTopic, null, `source null → topic null: ${msg}`); + } +} + +function assertChildSessionContainment(state: AgenticodingState): void { + for (const key of state.childSessions.keys()) { + assert.ok( + state.liveChildSessions.has(key), + `childSessions key "${key}" must be in liveChildSessions`, + ); + } +} + +function assertResetClears(state: AgenticodingState): void { + assert.equal(state.notebookPages.size, 0, "notebookPages must be empty after reset"); + assert.equal(state.childSessions.size, 0, "childSessions must be empty after reset"); + assert.equal(state.liveChildSessions.size, 0, "liveChildSessions must be empty after reset"); + assert.equal(state.epoch, 0, "epoch must be 0 after reset"); + assert.equal(state.activeNotebookTopic, null, "topic must be null after reset"); + assert.equal(state.activeNotebookTopicSource, null, "topic source must be null after reset"); + assert.equal(state.pendingHandoff, null, "pendingHandoff must be null after reset"); + assert.equal(state.pendingRequestedHandoff, null, "pendingRequestedHandoff must be null after reset"); + assert.equal(state.pendingTopicBoundaryHint, null, "pendingTopicBoundaryHint must be null after reset"); +} + +// ── Properties ──────────────────────────────────────────────────────── + +test("Property 1: Topic-source coupling invariant", async () => { + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + for (const action of actions) { + await apply(state, action); + assertTopicSourceCoupling(state); + } + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } +}); + +test("Property 2: Child session containment invariant", async () => { + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("addChildSession"), id: arbSessionId }), + fc.constant({ type: "abortChildren" } as StateAction), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + for (const action of actions) { + await apply(state, action); + assertChildSessionContainment(state); + } + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } +}); + +test("Property 3: childSessionEpoch only changes on reset", async () => { + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + fc.constant({ type: "abortChildren" } as StateAction), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + let expectedChildEpoch: number | null = null; + + for (const action of actions) { + const prevChildEpoch = state.childSessionEpoch; + await apply(state, action); + + if (action.type === "reset") { + // After reset, childSessionEpoch should have incremented + if (expectedChildEpoch === null) { + expectedChildEpoch = prevChildEpoch + 1; + } else { + expectedChildEpoch = state.childSessionEpoch; + } + assert.equal( + state.childSessionEpoch, + expectedChildEpoch, + `childSessionEpoch must be ${expectedChildEpoch} after reset (prev=${prevChildEpoch})`, + ); + expectedChildEpoch = state.childSessionEpoch; + } else { + // Non-reset: childSessionEpoch must be unchanged + assert.equal( + state.childSessionEpoch, + prevChildEpoch, + `childSessionEpoch must not change on ${action.type} action`, + ); + expectedChildEpoch = state.childSessionEpoch; + } + } + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } +}); + +test("Property 4: Reset clears all state fields", async () => { + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + fc.record({ type: fc.constant("addChildSession"), id: arbSessionId }), + fc.constant({ type: "abortChildren" } as StateAction), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + + for (const action of actions) { + await apply(state, action); + + // After every reset, assert full clear + if (action.type === "reset") { + assertResetClears(state); + } + } + + // Also test explicitly: create fresh state, perform some work, then reset + const s2 = createState(); + setActiveNotebookTopic(s2, "test-topic", "agent"); + await saveNotebookPage(mockPi, s2, "my-page", "some content"); + resetState(s2); + assertResetClears(s2); + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } +}); + +test("Property 5: Epoch monotonicity — non-zero after savePage", async () => { + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + fc.constant({ type: "clearTopic" } as StateAction), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + + assert.equal(state.epoch, 0, "epoch must be 0 on fresh state"); + + for (const action of actions) { + const prevEpoch: number = state.epoch; + await apply(state, action); + + if (action.type === "savePage") { + // After first savePage, epoch transitions from 0 to Date.now() (> 0) + // After subsequent saves, epoch is unchanged (set once) + assert.ok( + state.epoch > 0, + `epoch must be > 0 after savePage, got ${state.epoch}`, + ); + if (prevEpoch === 0) { + // First write: epoch transitions from 0 to Date.now() + assert.ok( + state.epoch >= Date.now() - 5000, + `epoch ${state.epoch} should be recent Date.now()`, + ); + } else { + // Subsequent writes: epoch unchanged + assert.equal(state.epoch, prevEpoch, "epoch must not change on subsequent savePage"); + } + } else if (action.type === "reset") { + // Reset sets epoch to 0 + assert.equal(state.epoch, 0, "epoch must be 0 after reset"); + } else { + // Non-save, non-reset: epoch unchanged + assert.equal(state.epoch, prevEpoch, "epoch unchanged on non-save/non-reset actions"); + } + } + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } +}); + +test("Property 6: childSessionEpoch monotonicity (never decreases)", async () => { + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + let maxSeenEpoch = 0; + + for (const action of actions) { + await apply(state, action); + assert.ok( + state.childSessionEpoch >= maxSeenEpoch, + `childSessionEpoch must never decrease: was ${maxSeenEpoch}, got ${state.childSessionEpoch}`, + ); + maxSeenEpoch = Math.max(maxSeenEpoch, state.childSessionEpoch); + } + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } +}); diff --git a/tests/unit/system-prompt.test.ts b/tests/unit/system-prompt.test.ts new file mode 100644 index 0000000..45e6a89 --- /dev/null +++ b/tests/unit/system-prompt.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { CONTEXT_PRIMER } from "../../system-prompt.js"; +import registerAgenticoding from "../../index.js"; +import { createTestPI, makeTUICtx } from "./helpers.js"; + +test("CONTEXT_PRIMER states the notebook, topic, and handoff contracts", () => { + assert.doesNotMatch(CONTEXT_PRIMER, /ledger/i, + "CONTEXT_PRIMER should contain zero stale ledger references after the rename"); + + const notebookParts = CONTEXT_PRIMER.split("### Notebook"); + const topicParts = CONTEXT_PRIMER.split("### Active notebook topic"); + const handoffParts = CONTEXT_PRIMER.split("### Handoff"); + const rulesParts = CONTEXT_PRIMER.split("### Rules"); + assert.equal(notebookParts.length, 2); + assert.equal(topicParts.length, 2); + assert.equal(handoffParts.length, 2); + assert.equal(rulesParts.length, 2); + + const notebookSection = notebookParts[1].split("### Active notebook topic")[0]; + const topicSection = topicParts[1].split("### Handoff")[0]; + const handoffSection = handoffParts[1].split("### Rules")[0]; + const rulesSection = rulesParts[1]; + + assert.match(notebookSection, /notebook_index/); + assert.match(notebookSection, /notebook_read/); + assert.match(notebookSection, /future contexts/i); + assert.match(topicSection, /semantic frame/i); + assert.match(topicSection, /prefer spawn/i); + assert.match(topicSection, /prefer handoff/i); + assert.match(handoffSection, /handoff/i); + assert.match(handoffSection, /notebook/i); + assert.match(rulesSection, /one subject, thread, or subsystem/i); +}); + + +test("before_agent_start injects notebook contracts plus live topic and page data", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); + const notebookWrite = pi.tools.get("notebook_write"); + await notebookWrite.execute("1", { name: "alpha", content: "first line\nsecond line" }, undefined, undefined, makeTUICtx()); + + const [handler] = pi.handlers.get("before_agent_start")!; + const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); + + assert.match(result.systemPrompt, /Base system prompt\./); + assert.match(result.systemPrompt, /## Context management/); + assert.match(result.systemPrompt, /## Active Notebook Topic/); + assert.match(result.systemPrompt, /Current topic: `oauth`/); + assert.match(result.systemPrompt, /## Active Notebook Pages/); + assert.match(result.systemPrompt, /notebook_read/); + assert.match(result.systemPrompt, /Reference pages by name/i); + assert.match(result.systemPrompt, /alpha: first line/); +}); + + +test("before_agent_start injects no-topic guidance when the topic is unset", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("before_agent_start")!; + const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); + + assert.match(result.systemPrompt, /## Active Notebook Topic/); + assert.match(result.systemPrompt, /No active notebook topic is set\./); + assert.match(result.systemPrompt, /notebook_topic_set/); +}); diff --git a/tests/unit/topic.test.ts b/tests/unit/topic.test.ts new file mode 100644 index 0000000..eb256ec --- /dev/null +++ b/tests/unit/topic.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { setActiveNotebookTopic, clearActiveNotebookTopic } from "../../notebook/topic.js"; +import { registerNotebookTopicTool } from "../../notebook/topic-tool.js"; +import { createTestPI } from "./helpers.js"; + +test("topic helpers manage the active notebook topic lifecycle", () => { + const state = createState(); + const first = setActiveNotebookTopic(state, "OAuth", "agent"); + assert.deepEqual(first, { + changed: true, + previous: null, + current: "oauth", + boundaryHint: null, + }); + const second = setActiveNotebookTopic(state, "Billing", "human"); + assert.equal(second.boundaryHint?.from, "oauth"); + assert.equal(second.boundaryHint?.to, "billing"); + clearActiveNotebookTopic(state); + assert.equal(state.activeNotebookTopic, null); + assert.equal(state.activeNotebookTopicSource, null); + assert.equal(state.pendingTopicBoundaryHint, null); +}); + +test("notebook_topic_set establishes a fresh topic, is idempotent, and refuses overrides", async () => { + const pi = createTestPI(); + const state = createState(); + registerNotebookTopicTool(pi as any, state); + + const tool = pi.tools.get("notebook_topic_set"); + const first = await tool.execute("1", { topic: "OAuth" }); + assert.equal(first.details.topic, "oauth"); + assert.equal(state.activeNotebookTopic, "oauth"); + assert.equal(state.activeNotebookTopicSource, "agent"); + + const second = await tool.execute("2", { topic: "oauth" }); + assert.equal(second.details.changed, false); + assert.equal(second.details.source, "agent"); + assert.match(second.content[0].text, /already set to "oauth"/i); + + await assert.rejects(() => tool.execute("3", { topic: "billing" }), /already exists/); +}); + + +test("notebook_topic_set preserves human authority, stays idempotent for equal topics, and rejects empty normalized topics", async () => { + const pi = createTestPI(); + const state = createState(); + registerNotebookTopicTool(pi as any, state); + const tool = pi.tools.get("notebook_topic_set"); + + setActiveNotebookTopic(state, "oauth", "human"); + const same = await tool.execute("1", { topic: "OAuth" }); + assert.equal(same.details.changed, false); + assert.equal(same.details.source, "human"); + assert.match(same.content[0].text, /already set to "oauth"/i); + await assert.rejects( + () => tool.execute("2", { topic: "billing" }), + /human-set notebook topic is authoritative/i, + ); + + const freshPi = createTestPI(); + const freshState = createState(); + registerNotebookTopicTool(freshPi as any, freshState); + const freshTool = freshPi.tools.get("notebook_topic_set"); + await assert.rejects( + () => freshTool.execute("3", { topic: "@@@" }), + /notebook topic cannot be empty/i, + ); +}); diff --git a/tests/unit/tui-indicators.test.ts b/tests/unit/tui-indicators.test.ts new file mode 100644 index 0000000..83c2a18 --- /dev/null +++ b/tests/unit/tui-indicators.test.ts @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { updateIndicators, STATUS_KEY_TOPIC } from "../../tui.js"; +import { makeTUICtx } from "./helpers.js"; + +test("updateIndicators sets context usage status with correct color tone", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 42, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("[accent:42%]"), "42% should use accent tone"); + assert.equal(record.widgets.get("agenticoding-warning"), undefined, "42% is below 70 — no warning widget"); +}); + +test("updateIndicators uses error tone at 70%+ context", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 85, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("[error:85%]"), "85% should use error tone"); + const w = record.widgets.get("agenticoding-warning"); + assert.ok(w?.[0]?.includes("85%"), "warning widget shown at 85%"); +}); + +test("updateIndicators uses warning tone at 50-69% context", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 55, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("[warning:55%]"), "55% should use warning tone"); +}); + +test("updateIndicators uses accent tone at 30-49% context", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 30, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("[accent:30%]"), "30% should use accent tone"); +}); + +test("updateIndicators handles null context usage", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: null, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("--%"), "null usage shows --%"); +}); + +test("updateIndicators no-ops when ctx.hasUI is false", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ hasUI: false, record }); + + updateIndicators(ctx, state); + assert.equal(record.statuses.size, 0, "no-op should not call any setStatus"); + assert.equal(record.widgets.size, 0, "no-op should not call any setWidget"); +}); + +test("updateIndicators shows notebook page count in status", () => { + const state = createState(); + state.notebookPages.set("entry-1", "first entry"); + state.notebookPages.set("entry-2", "second entry"); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: null, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-notebook"); + assert.ok(s?.includes("2"), "notebook page count should be 2"); +}); + +test("updateIndicators shows active notebook topic when set", () => { + const state = createState(); + state.activeNotebookTopic = "oauth"; + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 30, record }); + + updateIndicators(ctx, state); + assert.equal(record.statuses.get(STATUS_KEY_TOPIC), "🧭 oauth"); +}); + +test("updateIndicators hides widget below 70% context", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + // Pre-set a widget to verify it gets cleared + record.widgets.set("agenticoding-warning", ["existing"]); + const ctx = makeTUICtx({ percent: 30, record }); + + updateIndicators(ctx, state); + assert.equal(record.widgets.get("agenticoding-warning"), undefined, "warning widget should be cleared below 70%"); +}); diff --git a/tests/unit/watchdog.test.ts b/tests/unit/watchdog.test.ts new file mode 100644 index 0000000..2de756a --- /dev/null +++ b/tests/unit/watchdog.test.ts @@ -0,0 +1,158 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { registerWatchdog } from "../../watchdog.js"; +import { buildNudge } from "../../watchdog.js"; +import registerAgenticoding from "../../index.js"; +import { createTestPI } from "./helpers.js"; + +test("watchdog records context usage without user notifications", async () => { + const pi = createTestPI(); + const state = createState(); + registerWatchdog(pi as any, state); + const [handler] = pi.handlers.get("agent_end")!; + + const notifications: string[] = []; + await handler( + {}, + { + hasUI: true, + ui: { notify: (message: string) => notifications.push(message) }, + getContextUsage: () => ({ percent: 70 }), + }, + ); + + assert.equal(state.lastContextPercent, 70); + assert.deepEqual(notifications, []); +}); + +test("context injects watchdog reminder before each LLM call", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { + getContextUsage: () => ({ percent: 70 }), + }, + ); + + assert.equal(result.messages.length, 2); + assert.deepEqual(result.messages[0], { role: "user", content: "hi", timestamp: 1 }); + assert.equal(result.messages[1].role, "custom"); + assert.equal(result.messages[1].customType, "agenticoding-watchdog"); + assert.equal(result.messages[1].display, false); + assert.match(result.messages[1].content, /Context at 70%/); + assert.match(result.messages[1].content, /Active notebook topic: oauth/); + assert.match(result.messages[1].content, /spawn it instead of polluting the parent context/i); + assert.doesNotMatch(result.messages[1].content, /If you're mid-job and still clear|consider a handoff and draft a clear brief for what comes next/i); +}); + + +test("context injects a boundary nudge below 30% after an explicit topic change", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); + await pi.commands.get("notebook")!.handler("billing", { hasUI: false, getContextUsage: () => null }); + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { getContextUsage: () => ({ percent: 20 }) }, + ); + + assert.equal(result.messages[1].display, false); + assert.match(result.messages[1].content, /Notebook topic changed from oauth to billing/); +}); + + +test("context injects a no-topic nudge when context is high", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { getContextUsage: () => ({ percent: 70 }) }, + ); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[1].role, "custom"); + assert.equal(result.messages[1].customType, "agenticoding-watchdog"); + assert.equal(result.messages[1].display, false); + assert.match(result.messages[1].content, /No active notebook topic is set/); + assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i); +}); + + +test("context consumes a boundary hint after the first injected nudge", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); + await pi.commands.get("notebook")!.handler("billing", { hasUI: false, getContextUsage: () => null }); + + const first = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { getContextUsage: () => ({ percent: 20 }) }, + ); + assert.match(first.messages[1].content, /Notebook topic changed from oauth to billing/); + + const second = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, + { getContextUsage: () => ({ percent: 20 }) }, + ); + assert.equal(second, undefined); +}); + + +test("buildNudge no longer emits the old percent-only handoff text", () => { + const old = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null }, 46); + assert.doesNotMatch(old, /One context, one job\.|If you're mid-job and still clear|consider a handoff and draft a clear brief/i); + assert.match(old, /Active notebook topic: oauth/); + assert.match(old, /prefer spawn/i); +}); + + +test("buildNudge handles null percent and boundary hints before topic guidance", () => { + const boundary = buildNudge( + { + activeNotebookTopic: "oauth", + pendingTopicBoundaryHint: { from: "oauth", to: "billing", source: "human" }, + }, + null, + ); + assert.match(boundary, /Notebook topic changed from oauth to billing/); + assert.doesNotMatch(boundary, /Active notebook topic: oauth/); + + const noTopic = buildNudge({ activeNotebookTopic: null, pendingTopicBoundaryHint: null }, null); + assert.match(noTopic, /Topic-aware context reminder/); + assert.match(noTopic, /No active notebook topic is set/); +}); + +test("watchdog stays advisory when a requested handoff is not completed", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + registerWatchdog(pi as any, state); + const [handler] = pi.handlers.get("agent_end")!; + + const notifications: string[] = []; + await handler( + {}, + { + hasUI: true, + ui: { + notify: (message: string) => notifications.push(message), + setStatus: () => {}, + }, + getContextUsage: () => ({ percent: 20 }), + }, + ); + + assert.equal(state.pendingRequestedHandoff, null); + assert.deepEqual(notifications, []); + assert.deepEqual(pi.sentUserMessages, []); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3ef406e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["*.ts", "**/*.ts"] +} \ No newline at end of file